diff --git a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs index 4e592c575..6eb544c1d 100644 --- a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs +++ b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs @@ -148,10 +148,5 @@ public static string ConcatToString(this IEnumerable source, F { return string.Join(separator, source.Select(predicate)); } - - public static HashSet ToHashSet(this IEnumerable source, IEqualityComparer comparer = null) - { - return new HashSet(source, comparer); - } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs index faa7daa1b..8786961cd 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs @@ -13,6 +13,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests [TestFixture] public class TransmissionFixture : TransmissionFixtureBase { + [SetUp] + public void Setup_Transmission() + { + Mocker.GetMock() + .Setup(v => v.GetClientVersion(It.IsAny(), It.IsAny())) + .Returns("4.0.6"); + } + [Test] public void queued_item_should_have_required_properties() { @@ -272,7 +280,7 @@ public void should_fix_forward_slashes() public void should_only_check_version_number(string version) { Mocker.GetMock() - .Setup(s => s.GetClientVersion(It.IsAny())) + .Setup(s => s.GetClientVersion(It.IsAny(), true)) .Returns(version); Subject.Test().IsValid.Should().BeTrue(); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixtureBase.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixtureBase.cs index d6e2db822..4d4ad679e 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixtureBase.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixtureBase.cs @@ -29,7 +29,8 @@ public void Setup() Host = "127.0.0.1", Port = 2222, Username = "admin", - Password = "pass" + Password = "pass", + MovieCategory = "" }; Subject.Definition = new DownloadClientDefinition(); @@ -152,7 +153,7 @@ protected virtual void GivenTorrents(List torrents) } Mocker.GetMock() - .Setup(s => s.GetTorrents(It.IsAny())) + .Setup(s => s.GetTorrents(null, It.IsAny())) .Returns(torrents); } diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs index 1cfc134c5..23f3a4f4c 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Blocklisting; using NzbDrone.Core.Configuration; @@ -15,6 +17,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission { public class Transmission : TransmissionBase { + public override string Name => "Transmission"; + public override bool SupportsLabels => HasClientVersion(4, 0); + public Transmission(ITransmissionProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, IHttpClient httpClient, @@ -28,9 +33,48 @@ public Transmission(ITransmissionProxy proxy, { } + public override void MarkItemAsImported(DownloadClientItem downloadClientItem) + { + if (!SupportsLabels) + { + throw new NotSupportedException($"{Name} does not support marking items as imported"); + } + + // set post-import category + if (Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() && + Settings.MovieImportedCategory != Settings.MovieCategory) + { + var hash = downloadClientItem.DownloadId.ToLowerInvariant(); + var torrent = _proxy.GetTorrents(new[] { hash }, Settings).FirstOrDefault(); + + if (torrent == null) + { + _logger.Warn("Could not find torrent with hash \"{0}\" in Transmission.", hash); + return; + } + + try + { + var labels = torrent.Labels.ToHashSet(StringComparer.InvariantCultureIgnoreCase); + labels.Add(Settings.MovieImportedCategory); + + if (Settings.MovieCategory.IsNotNullOrWhiteSpace()) + { + labels.Remove(Settings.MovieCategory); + } + + _proxy.SetTorrentLabels(hash, labels, Settings); + } + catch (DownloadClientException ex) + { + _logger.Warn(ex, "Failed to set post-import torrent label \"{0}\" for {1} in Transmission.", Settings.MovieImportedCategory, downloadClientItem.Title); + } + } + } + protected override ValidationFailure ValidateVersion() { - var versionString = _proxy.GetClientVersion(Settings); + var versionString = _proxy.GetClientVersion(Settings, true); _logger.Debug("Transmission version information: {0}", versionString); @@ -44,7 +88,5 @@ protected override ValidationFailure ValidateVersion() return null; } - - public override string Name => "Transmission"; } } diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs index 61a342506..441d3d3c4 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; @@ -18,6 +19,8 @@ namespace NzbDrone.Core.Download.Clients.Transmission { public abstract class TransmissionBase : TorrentClientBase { + public abstract bool SupportsLabels { get; } + protected readonly ITransmissionProxy _proxy; public TransmissionBase(ITransmissionProxy proxy, @@ -37,7 +40,7 @@ public TransmissionBase(ITransmissionProxy proxy, public override IEnumerable GetItems() { var configFunc = new Lazy(() => _proxy.GetConfig(Settings)); - var torrents = _proxy.GetTorrents(Settings); + var torrents = _proxy.GetTorrents(null, Settings); var items = new List(); @@ -45,36 +48,45 @@ public override IEnumerable GetItems() { var outputPath = new OsPath(torrent.DownloadDir); - if (Settings.MovieDirectory.IsNotNullOrWhiteSpace()) + if (Settings.MovieCategory.IsNotNullOrWhiteSpace() && SupportsLabels && torrent.Labels is { Count: > 0 }) { - if (!new OsPath(Settings.MovieDirectory).Contains(outputPath)) + if (!torrent.Labels.Contains(Settings.MovieCategory, StringComparer.InvariantCultureIgnoreCase)) { continue; } } - else if (Settings.MovieCategory.IsNotNullOrWhiteSpace()) + else { - var directories = outputPath.FullPath.Split('\\', '/'); - if (!directories.Contains(Settings.MovieCategory)) + if (Settings.MovieDirectory.IsNotNullOrWhiteSpace()) { - continue; + if (!new OsPath(Settings.MovieDirectory).Contains(outputPath)) + { + continue; + } + } + else if (Settings.MovieCategory.IsNotNullOrWhiteSpace()) + { + var directories = outputPath.FullPath.Split('\\', '/'); + if (!directories.Contains(Settings.MovieCategory)) + { + continue; + } } } outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath); - var item = new DownloadClientItem(); - item.DownloadId = torrent.HashString.ToUpper(); - item.Category = Settings.MovieCategory; - item.Title = torrent.Name; - - item.DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, false); - - item.OutputPath = GetOutputPath(outputPath, torrent); - item.TotalSize = torrent.TotalSize; - item.RemainingSize = torrent.LeftUntilDone; - item.SeedRatio = torrent.DownloadedEver <= 0 ? 0 : - (double)torrent.UploadedEver / torrent.DownloadedEver; + var item = new DownloadClientItem + { + DownloadId = torrent.HashString.ToUpper(), + Category = Settings.MovieCategory, + Title = torrent.Name, + DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, Settings.MovieImportedCategory.IsNotNullOrWhiteSpace() && SupportsLabels), + OutputPath = GetOutputPath(outputPath, torrent), + TotalSize = torrent.TotalSize, + RemainingSize = torrent.LeftUntilDone, + SeedRatio = torrent.DownloadedEver <= 0 ? 0 : (double)torrent.UploadedEver / torrent.DownloadedEver + }; if (torrent.Eta >= 0) { @@ -300,7 +312,7 @@ private ValidationFailure TestGetTorrents() { try { - _proxy.GetTorrents(Settings); + _proxy.GetTorrents(null, Settings); } catch (Exception ex) { @@ -310,5 +322,15 @@ private ValidationFailure TestGetTorrents() return null; } + + protected bool HasClientVersion(int major, int minor) + { + var rawVersion = _proxy.GetClientVersion(Settings); + + var versionResult = Regex.Match(rawVersion, @"(?= new Version(major, minor); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs index 45190fb16..2de2b26e7 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Linq; using System.Net; using Newtonsoft.Json.Linq; using NLog; @@ -12,15 +15,16 @@ namespace NzbDrone.Core.Download.Clients.Transmission { public interface ITransmissionProxy { - List GetTorrents(TransmissionSettings settings); + IReadOnlyCollection GetTorrents(IReadOnlyCollection hashStrings, TransmissionSettings settings); void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, TransmissionSettings settings); void AddTorrentFromData(byte[] torrentData, string downloadDirectory, TransmissionSettings settings); void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, TransmissionSettings settings); TransmissionConfig GetConfig(TransmissionSettings settings); string GetProtocolVersion(TransmissionSettings settings); - string GetClientVersion(TransmissionSettings settings); + string GetClientVersion(TransmissionSettings settings, bool force = false); void RemoveTorrent(string hash, bool removeData, TransmissionSettings settings); void MoveTorrentToTopInQueue(string hashString, TransmissionSettings settings); + void SetTorrentLabels(string hash, IEnumerable labels, TransmissionSettings settings); } public class TransmissionProxy : ITransmissionProxy @@ -28,50 +32,66 @@ public class TransmissionProxy : ITransmissionProxy private readonly IHttpClient _httpClient; private readonly Logger _logger; - private ICached _authSessionIDCache; + private readonly ICached _authSessionIdCache; + private readonly ICached _versionCache; public TransmissionProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger) { _httpClient = httpClient; _logger = logger; - _authSessionIDCache = cacheManager.GetCache(GetType(), "authSessionID"); + _authSessionIdCache = cacheManager.GetCache(GetType(), "authSessionID"); + _versionCache = cacheManager.GetCache(GetType(), "versions"); } - public List GetTorrents(TransmissionSettings settings) + public IReadOnlyCollection GetTorrents(IReadOnlyCollection hashStrings, TransmissionSettings settings) { - var result = GetTorrentStatus(settings); + var result = GetTorrentStatus(hashStrings, settings); - var torrents = ((JArray)result.Arguments["torrents"]).ToObject>(); + var torrents = ((JArray)result.Arguments["torrents"]).ToObject>(); return torrents; } public void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, TransmissionSettings settings) { - var arguments = new Dictionary(); - arguments.Add("filename", torrentUrl); - arguments.Add("paused", settings.AddPaused); + var arguments = new Dictionary + { + { "filename", torrentUrl }, + { "paused", settings.AddPaused } + }; if (!downloadDirectory.IsNullOrWhiteSpace()) { arguments.Add("download-dir", downloadDirectory); } + if (settings.MovieCategory.IsNotNullOrWhiteSpace()) + { + arguments.Add("labels", new List { settings.MovieCategory }); + } + ProcessRequest("torrent-add", arguments, settings); } public void AddTorrentFromData(byte[] torrentData, string downloadDirectory, TransmissionSettings settings) { - var arguments = new Dictionary(); - arguments.Add("metainfo", Convert.ToBase64String(torrentData)); - arguments.Add("paused", settings.AddPaused); + var arguments = new Dictionary + { + { "metainfo", Convert.ToBase64String(torrentData) }, + { "paused", settings.AddPaused } + }; if (!downloadDirectory.IsNullOrWhiteSpace()) { arguments.Add("download-dir", downloadDirectory); } + if (settings.MovieCategory.IsNotNullOrWhiteSpace()) + { + arguments.Add("labels", new List { settings.MovieCategory }); + } + ProcessRequest("torrent-add", arguments, settings); } @@ -82,8 +102,10 @@ public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration return; } - var arguments = new Dictionary(); - arguments.Add("ids", new[] { hash }); + var arguments = new Dictionary + { + { "ids", new List { hash } } + }; if (seedConfiguration.Ratio != null) { @@ -97,6 +119,12 @@ public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration arguments.Add("seedIdleMode", 1); } + // Avoid extraneous request if no limits are to be set + if (arguments.All(arg => arg.Key == "ids")) + { + return; + } + ProcessRequest("torrent-set", arguments, settings); } @@ -107,11 +135,16 @@ public string GetProtocolVersion(TransmissionSettings settings) return config.RpcVersion; } - public string GetClientVersion(TransmissionSettings settings) + public string GetClientVersion(TransmissionSettings settings, bool force = false) { - var config = GetConfig(settings); + var cacheKey = $"version:{$"{GetBaseUrl(settings)}:{settings.Password}".SHA256Hash()}"; - return config.Version; + if (force) + { + _versionCache.Remove(cacheKey); + } + + return _versionCache.Get(cacheKey, () => GetConfig(settings).Version, TimeSpan.FromHours(6)); } public TransmissionConfig GetConfig(TransmissionSettings settings) @@ -124,21 +157,36 @@ public TransmissionConfig GetConfig(TransmissionSettings settings) public void RemoveTorrent(string hashString, bool removeData, TransmissionSettings settings) { - var arguments = new Dictionary(); - arguments.Add("ids", new string[] { hashString }); - arguments.Add("delete-local-data", removeData); + var arguments = new Dictionary + { + { "ids", new List { hashString } }, + { "delete-local-data", removeData } + }; ProcessRequest("torrent-remove", arguments, settings); } public void MoveTorrentToTopInQueue(string hashString, TransmissionSettings settings) { - var arguments = new Dictionary(); - arguments.Add("ids", new string[] { hashString }); + var arguments = new Dictionary + { + { "ids", new List { hashString } } + }; ProcessRequest("queue-move-top", arguments, settings); } + public void SetTorrentLabels(string hash, IEnumerable labels, TransmissionSettings settings) + { + var arguments = new Dictionary + { + { "ids", new List { hash } }, + { "labels", labels.ToImmutableHashSet() } + }; + + ProcessRequest("torrent-set", arguments, settings); + } + private TransmissionResponse GetSessionVariables(TransmissionSettings settings) { // Retrieve transmission information such as the default download directory, bandwidth throttling and seed ratio. @@ -151,14 +199,9 @@ private TransmissionResponse GetSessionStatistics(TransmissionSettings settings) return ProcessRequest("session-stats", null, settings); } - private TransmissionResponse GetTorrentStatus(TransmissionSettings settings) - { - return GetTorrentStatus(null, settings); - } - private TransmissionResponse GetTorrentStatus(IEnumerable hashStrings, TransmissionSettings settings) { - var fields = new string[] + var fields = new List { "id", "hashString", // Unique torrent ID. Use this instead of the client id? @@ -179,11 +222,14 @@ private TransmissionResponse GetTorrentStatus(IEnumerable hashStrings, T "seedIdleLimit", "seedIdleMode", "fileCount", - "file-count" + "file-count", + "labels" }; - var arguments = new Dictionary(); - arguments.Add("fields", fields); + var arguments = new Dictionary + { + { "fields", fields } + }; if (hashStrings != null) { @@ -195,9 +241,14 @@ private TransmissionResponse GetTorrentStatus(IEnumerable hashStrings, T return result; } + private string GetBaseUrl(TransmissionSettings settings) + { + return HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + } + private HttpRequestBuilder BuildRequest(TransmissionSettings settings) { - var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + var requestBuilder = new HttpRequestBuilder(GetBaseUrl(settings)) .Resource("rpc") .Accept(HttpAccept.Json); @@ -212,11 +263,11 @@ private void AuthenticateClient(HttpRequestBuilder requestBuilder, TransmissionS { var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); - var sessionId = _authSessionIDCache.Find(authKey); + var sessionId = _authSessionIdCache.Find(authKey); if (sessionId == null || reauthenticate) { - _authSessionIDCache.Remove(authKey); + _authSessionIdCache.Remove(authKey); var authLoginRequest = BuildRequest(settings).Build(); authLoginRequest.SuppressHttpError = true; @@ -244,7 +295,7 @@ private void AuthenticateClient(HttpRequestBuilder requestBuilder, TransmissionS _logger.Debug("Transmission authentication succeeded."); - _authSessionIDCache.Set(authKey, sessionId); + _authSessionIdCache.Set(authKey, sessionId); } requestBuilder.SetHeader("X-Transmission-Session-Id", sessionId); diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs index ae9be1acd..9e8c86e9d 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs @@ -32,6 +32,7 @@ public TransmissionSettings() Host = "localhost"; Port = 9091; UrlBase = "/transmission/"; + MovieCategory = "radarr"; } [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] @@ -59,16 +60,19 @@ public TransmissionSettings() [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsCategorySubFolderHelpText")] public string MovieCategory { get; set; } - [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTransmissionSettingsDirectoryHelpText")] + [FieldDefinition(7, Label = "PostImportCategory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsPostImportCategoryHelpText")] + public string MovieImportedCategory { get; set; } + + [FieldDefinition(8, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTransmissionSettingsDirectoryHelpText")] public string MovieDirectory { get; set; } - [FieldDefinition(8, Label = "DownloadClientSettingsRecentPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsRecentPriorityMovieHelpText")] + [FieldDefinition(9, Label = "DownloadClientSettingsRecentPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsRecentPriorityMovieHelpText")] public int RecentMoviePriority { get; set; } - [FieldDefinition(9, Label = "DownloadClientSettingsOlderPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsOlderPriorityMovieHelpText")] + [FieldDefinition(10, Label = "DownloadClientSettingsOlderPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsOlderPriorityMovieHelpText")] public int OlderMoviePriority { get; set; } - [FieldDefinition(10, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)] + [FieldDefinition(11, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)] public bool AddPaused { get; set; } public override NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs index 4e66b7a02..687bab40b 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.Transmission @@ -11,6 +13,7 @@ public class TransmissionTorrent public long TotalSize { get; set; } public long LeftUntilDone { get; set; } public bool IsFinished { get; set; } + public IReadOnlyCollection Labels { get; set; } = Array.Empty(); public long Eta { get; set; } public TransmissionTorrentStatus Status { get; set; } public long SecondsDownloading { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs index 234ec43c2..2c5a84d60 100644 --- a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs +++ b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs @@ -15,6 +15,9 @@ public class Vuze : TransmissionBase { private const int MINIMUM_SUPPORTED_PROTOCOL_VERSION = 14; + public override string Name => "Vuze"; + public override bool SupportsLabels => false; + public Vuze(ITransmissionProxy proxy, ITorrentFileInfoReader torrentFileInfoReader, IHttpClient httpClient, @@ -67,7 +70,5 @@ protected override ValidationFailure ValidateVersion() return null; } - - public override string Name => "Vuze"; } }