diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs new file mode 100644 index 000000000..6a9d001bd --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs @@ -0,0 +1,222 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients.Hadouken; +using NzbDrone.Core.Download.Clients.Hadouken.Models; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests +{ + [TestFixture] + public class HadoukenFixture : DownloadClientFixtureBase + { + protected HadoukenTorrent _queued; + protected HadoukenTorrent _downloading; + protected HadoukenTorrent _failed; + protected HadoukenTorrent _completed; + + [SetUp] + public void Setup() + { + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new HadoukenSettings(); + + _queued = new HadoukenTorrent + { + InfoHash= "HASH", + IsFinished = false, + State = HadoukenTorrentState.QueuedForChecking, + Name = _title, + TotalSize = 1000, + DownloadedBytes = 0, + Progress = 0.0, + SavePath = "somepath" + }; + + _downloading = new HadoukenTorrent + { + InfoHash = "HASH", + IsFinished = false, + State = HadoukenTorrentState.Downloading, + Name = _title, + TotalSize = 1000, + DownloadedBytes = 100, + Progress = 10.0, + SavePath = "somepath" + }; + + _failed = new HadoukenTorrent + { + InfoHash = "HASH", + IsFinished = false, + State = HadoukenTorrentState.Downloading, + Error = "some error", + Name = _title, + TotalSize = 1000, + DownloadedBytes = 100, + Progress = 10.0, + SavePath= "somepath" + }; + + _completed = new HadoukenTorrent + { + InfoHash = "HASH", + IsFinished = true, + State = HadoukenTorrentState.Downloading, + IsPaused = true, + Name = _title, + TotalSize = 1000, + DownloadedBytes = 1000, + Progress = 100.0, + SavePath = "somepath" + }; + + Mocker.GetMock() + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[0])); + } + + protected void GivenFailedDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentUri(It.IsAny(), It.IsAny())) + .Throws(); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFile(It.IsAny(), It.IsAny())) + .Throws(); + } + + protected void GivenSuccessfulDownload() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[1000])); + + Mocker.GetMock() + .Setup(s => s.AddTorrentUri(It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFile(It.IsAny(), It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951".ToLower()) + .Callback(PrepareClientToReturnQueuedItem); + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + Mocker.GetMock() + .Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents.ToDictionary(k => k.InfoHash)); + } + + protected void PrepareClientToReturnQueuedItem() + { + GivenTorrents(new List + { + _queued + }); + } + + protected void PrepareClientToReturnDownloadingItem() + { + GivenTorrents(new List + { + _downloading + }); + } + + protected void PrepareClientToReturnFailedItem() + { + GivenTorrents(new List + { + _failed + }); + } + + protected void PrepareClientToReturnCompletedItem() + { + GivenTorrents(new List + { + _completed + }); + } + + [Test] + public void queued_item_should_have_required_properties() + { + PrepareClientToReturnQueuedItem(); + var item = Subject.GetItems().Single(); + VerifyQueued(item); + } + + [Test] + public void downloading_item_should_have_required_properties() + { + PrepareClientToReturnDownloadingItem(); + var item = Subject.GetItems().Single(); + VerifyDownloading(item); + } + + [Test] + public void failed_item_should_have_required_properties() + { + PrepareClientToReturnFailedItem(); + var item = Subject.GetItems().Single(); + VerifyWarning(item); + } + + [Test] + public void completed_download_should_have_required_properties() + { + PrepareClientToReturnCompletedItem(); + var item = Subject.GetItems().Single(); + VerifyCompleted(item); + } + + [Test] + public void Download_should_return_unique_id() + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + } + + [Test] + public void should_return_status_with_outputdirs() + { + var configItems = new Dictionary(); + + configItems.Add("bittorrent.defaultSavePath", @"C:\Downloads\Downloading\deluge".AsOsAgnostic()); + + Mocker.GetMock() + .Setup(v => v.GetConfig(It.IsAny())) + .Returns(configItems); + + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(@"C:\Downloads\Downloading\deluge".AsOsAgnostic()); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index b1da9d2b2..a1c654e79 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -164,6 +164,7 @@ + diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/AuthenticationType.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/AuthenticationType.cs new file mode 100644 index 000000000..686673286 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/AuthenticationType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public enum AuthenticationType + { + None = 0, + Basic, + Token + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs new file mode 100644 index 000000000..630a5afa5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.Hadouken.Models; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public sealed class Hadouken : TorrentClientBase + { + private readonly IHadoukenProxy _proxy; + + public Hadouken(IHadoukenProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) + { + _proxy = proxy; + } + + public override string Name + { + get { return "Hadouken"; } + } + + public override IEnumerable GetItems() + { + IDictionary torrents; + + try + { + torrents = _proxy.GetTorrents(Settings); + } + catch (DownloadClientException ex) + { + _logger.ErrorException(ex.Message, ex); + return Enumerable.Empty(); + } + + var items = new List(); + + foreach (var torrent in torrents.Values) + { + var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)); + outputPath += torrent.Name; + + var eta = TimeSpan.FromSeconds(0); + + if (torrent.DownloadRate > 0 && torrent.TotalSize > 0) + { + eta = TimeSpan.FromSeconds(torrent.TotalSize/(double) torrent.DownloadRate); + } + + var item = new DownloadClientItem + { + DownloadClient = Definition.Name, + DownloadId = torrent.InfoHash, + OutputPath = outputPath + torrent.Name, + RemainingSize = torrent.TotalSize - torrent.DownloadedBytes, + RemainingTime = eta, + Title = torrent.Name, + TotalSize = torrent.TotalSize + }; + + if (!string.IsNullOrEmpty(torrent.Error)) + { + item.Status = DownloadItemStatus.Warning; + item.Message = torrent.Error; + } + else if (torrent.IsFinished && torrent.State != HadoukenTorrentState.CheckingFiles) + { + item.Status = DownloadItemStatus.Completed; + } + else if (torrent.State == HadoukenTorrentState.QueuedForChecking) + { + item.Status = DownloadItemStatus.Queued; + } + else if (torrent.IsPaused) + { + item.Status = DownloadItemStatus.Paused; + } + else + { + item.Status = DownloadItemStatus.Downloading; + } + + items.Add(item); + } + + return items; + } + + public override void RemoveItem(string downloadId, bool deleteData) + { + _proxy.RemoveTorrent(Settings, downloadId, deleteData); + } + + public override DownloadClientStatus GetStatus() + { + var config = _proxy.GetConfig(Settings); + var destDir = new OsPath(config.GetValueOrDefault("bittorrent.defaultSavePath") as string); + + var status = new DownloadClientStatus + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" + }; + + if (!destDir.IsEmpty) + { + status.OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) }; + } + + return status; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.Any()) return; + failures.AddIfNotNull(TestGetTorrents()); + } + + protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + { + _proxy.AddTorrentUri(Settings, magnetLink); + return hash; + } + + protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + { + return _proxy.AddTorrentFile(Settings, fileContent).ToUpper(); + } + + private ValidationFailure TestConnection() + { + try + { + var sysInfo = _proxy.GetSystemInfo(Settings); + var version = new Version(sysInfo.Versions["hadouken"]); + + if (version.Major < 5) + { + return new ValidationFailure(string.Empty, "Old Hadouken client with unsupported API, need 5.0 or higher"); + } + } + catch (DownloadClientAuthenticationException ex) + { + _logger.ErrorException(ex.Message, ex); + + if (Settings.AuthenticationType == (int) AuthenticationType.Token) + { + return new NzbDroneValidationFailure("Token", "Authentication failed"); + } + + return new NzbDroneValidationFailure("Password", "Authentication failed"); + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure(String.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenError.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenError.cs new file mode 100644 index 000000000..2d8c64b4f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenError.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public class HadoukenError + { + public int Code { get; set; } + public string Message { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs new file mode 100644 index 000000000..8991189c3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Text; +using NLog; +using NzbDrone.Core.Download.Clients.Hadouken.Models; +using NzbDrone.Core.Rest; +using RestSharp; + +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public sealed class HadoukenProxy : IHadoukenProxy + { + private static int _callId; + private readonly Logger _logger; + + public HadoukenProxy(Logger logger) + { + _logger = logger; + } + + public HadoukenSystemInfo GetSystemInfo(HadoukenSettings settings) + { + return ProcessRequest(settings, "core.getSystemInfo").Result; + } + + public IDictionary GetTorrents(HadoukenSettings settings) + { + return ProcessRequest>(settings, "session.getTorrents").Result; + } + + public IDictionary GetConfig(HadoukenSettings settings) + { + return ProcessRequest>(settings, "config.get").Result; + } + + public string AddTorrentFile(HadoukenSettings settings, byte[] fileContent) + { + return ProcessRequest(settings, "session.addTorrentFile", Convert.ToBase64String(fileContent)).Result; + } + + public void AddTorrentUri(HadoukenSettings settings, string torrentUrl) + { + ProcessRequest(settings, "session.addTorrentUri", torrentUrl); + } + + public void RemoveTorrent(HadoukenSettings settings, string downloadId, bool deleteData) + { + ProcessRequest(settings, "session.removeTorrent", downloadId, deleteData); + } + + private HadoukenResponse ProcessRequest(HadoukenSettings settings, + string method, + params object[] parameters) + { + var client = BuildClient(settings); + return ProcessRequest(client, method, parameters); + } + + private HadoukenResponse ProcessRequest(IRestClient client, string method, params object[] parameters) + { + var request = new RestRequest(Method.POST); + request.Resource = "api"; + request.RequestFormat = DataFormat.Json; + request.AddHeader("Accept-Encoding", "gzip,deflate"); + + var data = new Dictionary(); + data.Add("id", GetCallId()); + data.Add("method", method); + + if (parameters != null) + { + data.Add("params", parameters); + } + + request.AddBody(data); + + _logger.Debug("Url: {0} Method: {1}", client.BuildUri(request), method); + return client.ExecuteAndValidate>(request); + } + + private IRestClient BuildClient(HadoukenSettings settings) + { + var protocol = settings.UseSsl ? "https" : "http"; + var url = string.Format(@"{0}://{1}:{2}", protocol, settings.Host, settings.Port); + + var restClient = RestClientFactory.BuildClient(url); + restClient.Timeout = 4000; + + if (settings.AuthenticationType == (int) AuthenticationType.Basic) + { + var basicData = Encoding.UTF8.GetBytes(string.Format("{0}:{1}", settings.Username, settings.Password)); + var basicHeader = Convert.ToBase64String(basicData); + + restClient.AddDefaultHeader("Authorization", string.Format("Basic {0}", basicHeader)); + } + else if (settings.AuthenticationType == (int) AuthenticationType.Token) + { + restClient.AddDefaultHeader("Authorization", string.Format("Token {0}", settings.Token)); + } + + return restClient; + } + + private int GetCallId() + { + return System.Threading.Interlocked.Increment(ref _callId); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenResponse.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenResponse.cs new file mode 100644 index 000000000..dd9029bc1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenResponse.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public class HadoukenResponse + { + public int Id { get; set; } + public TResult Result { get; set; } + public HadoukenError Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs new file mode 100644 index 000000000..f6342d96c --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs @@ -0,0 +1,43 @@ +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public sealed class HadoukenSettings : IProviderConfig + { + private static readonly HadoukenSettingsValidator Validator = new HadoukenSettingsValidator(); + + public HadoukenSettings() + { + Host = "localhost"; + Port = 7070; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] + public int Port { get; set; } + + [FieldDefinition(2, Label = "Auth. type", Type = FieldType.Select, SelectOptions = typeof(AuthenticationType), HelpText = "How to authenticate against Hadouken.")] + public int AuthenticationType { get; set; } + + [FieldDefinition(3, Label = "Username", Type = FieldType.Textbox, HelpText = "Only used for basic auth.")] + public string Username { get; set; } + + [FieldDefinition(4, Label = "Password", Type = FieldType.Password, HelpText = "Only used for basic auth.")] + public string Password { get; set; } + + [FieldDefinition(5, Label = "Token", Type = FieldType.Password, HelpText = "Only used for token auth.")] + public string Token { get; set; } + + [FieldDefinition(6, Label = "Use SSL", Type = FieldType.Checkbox, Advanced = true)] + public bool UseSsl { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettingsValidator.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettingsValidator.cs new file mode 100644 index 000000000..cec997ab7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettingsValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public sealed class HadoukenSettingsValidator : AbstractValidator + { + public HadoukenSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).GreaterThan(0); + + RuleFor(c => c.Token).NotEmpty() + .When(c => c.AuthenticationType == (int) AuthenticationType.Token) + .WithMessage("Token must not be empty when using token auth."); + + RuleFor(c => c.Username).NotEmpty() + .When(c => c.AuthenticationType == (int)AuthenticationType.Basic) + .WithMessage("Username must not be empty when using basic auth."); + + RuleFor(c => c.Password).NotEmpty() + .When(c => c.AuthenticationType == (int)AuthenticationType.Basic) + .WithMessage("Password must not be empty when using basic auth."); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/IHadoukenProxy.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/IHadoukenProxy.cs new file mode 100644 index 000000000..7a3bc4f34 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/IHadoukenProxy.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Core.Download.Clients.Hadouken.Models; + +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public interface IHadoukenProxy + { + HadoukenSystemInfo GetSystemInfo(HadoukenSettings settings); + IDictionary GetTorrents(HadoukenSettings settings); + IDictionary GetConfig(HadoukenSettings settings); + string AddTorrentFile(HadoukenSettings settings, byte[] fileContent); + void AddTorrentUri(HadoukenSettings settings, string torrentUrl); + void RemoveTorrent(HadoukenSettings settings, string downloadId, bool deleteData); + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenSystemInfo.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenSystemInfo.cs new file mode 100644 index 000000000..6d3296efb --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenSystemInfo.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Hadouken.Models +{ + public sealed class HadoukenSystemInfo + { + public string Commitish { get; set; } + public string Branch { get; set; } + public Dictionary Versions { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs new file mode 100644 index 000000000..d75bafdc7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs @@ -0,0 +1,18 @@ +namespace NzbDrone.Core.Download.Clients.Hadouken.Models +{ + public sealed class HadoukenTorrent + { + public string InfoHash { get; set; } + public double Progress { get; set; } + public string Name { get; set; } + public string SavePath { get; set; } + public HadoukenTorrentState State { get; set; } + public bool IsFinished { get; set; } + public bool IsPaused { get; set; } + public bool IsSeeding { get; set; } + public long TotalSize { get; set; } + public long DownloadedBytes { get; set; } + public long DownloadRate { get; set; } + public string Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentState.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentState.cs new file mode 100644 index 000000000..64cdaa0ea --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentState.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Download.Clients.Hadouken.Models +{ + public enum HadoukenTorrentState + { + QueuedForChecking = 0, + CheckingFiles = 1, + DownloadingMetadata = 2, + Downloading = 3, + Finished = 4, + Seeding = 5, + Allocating = 6, + CheckingResumeData = 7 + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 0f5b94c59..abbedfad7 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -348,6 +348,17 @@ + + + + + + + + + + +