diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/PutioTests/PutioFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/PutioTests/PutioFixture.cs new file mode 100644 index 000000000..a90a7bf1c --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/PutioTests/PutioFixture.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients.Putio; +using NzbDrone.Core.MediaFiles.TorrentInfo; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.PutioTests +{ + public class PutioFixture : DownloadClientFixtureBase + { + private PutioSettings _settings; + private PutioTorrent _queued; + private PutioTorrent _downloading; + private PutioTorrent _failed; + private PutioTorrent _completed; + private PutioTorrent _completed_different_parent; + private PutioTorrent _seeding; + + [SetUp] + public void Setup() + { + _settings = new PutioSettings + { + SaveParentId = "1" + }; + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = _settings; + + _queued = new PutioTorrent + { + Hash = "HASH", + Id = 1, + Status = PutioTorrentStatus.InQueue, + Name = _title, + Size = 1000, + Downloaded = 0, + SaveParentId = 1 + }; + + _downloading = new PutioTorrent + { + Hash = "HASH", + Id = 2, + Status = PutioTorrentStatus.Downloading, + Name = _title, + Size = 1000, + Downloaded = 980, + SaveParentId = 1, + }; + + _failed = new PutioTorrent + { + Hash = "HASH", + Id = 3, + Status = PutioTorrentStatus.Error, + ErrorMessage = "Torrent has reached the maximum number of inactive days.", + Name = _title, + Size = 1000, + Downloaded = 980, + SaveParentId = 1, + }; + + _completed = new PutioTorrent + { + Hash = "HASH", + Status = PutioTorrentStatus.Completed, + Id = 4, + Name = _title, + Size = 1000, + Downloaded = 1000, + SaveParentId = 1, + FileId = 2 + }; + + _completed_different_parent = new PutioTorrent + { + Hash = "HASH", + Id = 5, + Status = PutioTorrentStatus.Completed, + Name = _title, + Size = 1000, + Downloaded = 1000, + SaveParentId = 2, + FileId = 3 + }; + + _seeding = new PutioTorrent + { + Hash = "HASH", + Id = 6, + Status = PutioTorrentStatus.Seeding, + Name = _title, + Size = 1000, + Downloaded = 1000, + Uploaded = 1300, + SaveParentId = 1, + FileId = 4 + }; + + 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(), Array.Empty())); + + Mocker.GetMock() + .Setup(v => v.GetAccountSettings(It.IsAny())); + + Mocker.GetMock() + .Setup(v => v.GetFileListingResponse(It.IsAny(), It.IsAny())) + .Returns(PutioFileListingResponse.Empty()); + } + + protected void GivenFailedDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Throws(); + } + + protected void GivenSuccessfulDownload() + { + GivenRemoteFileStructure(new List + { + new PutioFile { Id = _completed.FileId, Name = _title, FileType = PutioFile.FILE_TYPE_VIDEO }, + new PutioFile { Id = _seeding.FileId, Name = _title, FileType = PutioFile.FILE_TYPE_FOLDER }, + }, new PutioFile { Id = 1, Name = "Downloads" }); + + // GivenRemoteFileStructure(new List + // { + // new PutioFile { Id = _completed_different_parent.FileId, Name = _title, FileType = PutioFile.FILE_TYPE_VIDEO }, + // }, new PutioFile { Id = 2, Name = "Downloads_new" }); + } + + protected virtual void GivenTorrents(List torrents) + { + torrents ??= new List(); + + Mocker.GetMock() + .Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents); + } + + protected virtual void GivenRemoteFileStructure(List files, PutioFile parentFile) + { + files ??= new List(); + var list = new PutioFileListingResponse { Files = files, Parent = parentFile }; + + Mocker.GetMock() + .Setup(s => s.GetFileListingResponse(parentFile.Id, It.IsAny())) + .Returns(list); + + Mocker.GetMock() + .Setup(s => s.FolderExists(It.IsAny())) + .Returns(true); + } + + protected virtual void GivenMetadata(List metadata) + { + metadata ??= new List(); + var result = new Dictionary(); + foreach (var item in metadata) + { + result.Add(item.Id.ToString(), item); + } + + Mocker.GetMock() + .Setup(s => s.GetAllTorrentMetadata(It.IsAny())) + .Returns(result); + } + + [Test] + public void getItems_contains_all_items() + { + GivenTorrents(new List + { + _queued, + _downloading, + _failed, + _completed, + _seeding, + _completed_different_parent + }); + GivenSuccessfulDownload(); + + var items = Subject.GetItems(); + + VerifyQueued(items.ElementAt(0)); + VerifyDownloading(items.ElementAt(1)); + VerifyWarning(items.ElementAt(2)); + VerifyCompleted(items.ElementAt(3)); + VerifyCompleted(items.ElementAt(4)); + + items.Should().HaveCount(5); + } + + [TestCase(1, 5)] + [TestCase(2, 1)] + [TestCase(3, 0)] + public void getItems_contains_only_items_with_matching_parent_id(long configuredParentId, int expectedCount) + { + GivenTorrents(new List + { + _queued, + _downloading, + _failed, + _completed, + _seeding, + _completed_different_parent + }); + GivenSuccessfulDownload(); + + _settings.SaveParentId = configuredParentId.ToString(); + + Subject.GetItems().Should().HaveCount(expectedCount); + } + + [TestCase("WAITING", DownloadItemStatus.Queued)] + [TestCase("PREPARING_DOWNLOAD", DownloadItemStatus.Queued)] + [TestCase("COMPLETED", DownloadItemStatus.Completed)] + [TestCase("COMPLETING", DownloadItemStatus.Downloading)] + [TestCase("DOWNLOADING", DownloadItemStatus.Downloading)] + [TestCase("ERROR", DownloadItemStatus.Failed)] + [TestCase("IN_QUEUE", DownloadItemStatus.Queued)] + [TestCase("SEEDING", DownloadItemStatus.Completed)] + public void test_getItems_maps_download_status(string given, DownloadItemStatus expectedItemStatus) + { + _queued.Status = given; + + GivenTorrents(new List + { + _queued + }); + + var item = Subject.GetItems().Single(); + + item.Status.Should().Be(expectedItemStatus); + } + + [Test] + public void test_getItems_path_for_folders() + { + GivenTorrents(new List { _completed }); + GivenRemoteFileStructure(new List + { + new PutioFile { Id = _completed.FileId, Name = _title, FileType = PutioFile.FILE_TYPE_FOLDER }, + }, new PutioFile { Id = 1, Name = "Downloads" }); + + var item = Subject.GetItems().Single(); + + VerifyCompleted(item); + item.OutputPath.ToString().Should().ContainAll("Downloads", _title); + + Mocker.GetMock() + .Verify(s => s.GetFileListingResponse(1, It.IsAny()), Times.AtLeastOnce()); + } + + [Test] + public void test_getItems_path_for_files() + { + GivenTorrents(new List { _completed }); + GivenRemoteFileStructure(new List + { + new PutioFile { Id = _completed.FileId, Name = _title, FileType = PutioFile.FILE_TYPE_VIDEO }, + }, new PutioFile { Id = 1, Name = "Downloads" }); + + var item = Subject.GetItems().Single(); + + VerifyCompleted(item); + + item.OutputPath.ToString().Should().Contain("Downloads"); + item.OutputPath.ToString().Should().NotContain(_title); + + Mocker.GetMock() + .Verify(s => s.GetFileListingResponse(It.IsAny(), It.IsAny()), Times.AtLeastOnce()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/PutioTests/PutioProxyFixtures.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/PutioTests/PutioProxyFixtures.cs new file mode 100644 index 000000000..12c8c6d15 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/PutioTests/PutioProxyFixtures.cs @@ -0,0 +1,132 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Clients.Putio; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.PutioTests +{ + public class PutioProxyFixtures : CoreTest + { + [Test] + public void test_GetTorrentMetadata_createsNewObject() + { + ClientGetWillReturn("{\"status\":\"OK\",\"value\":null}"); + + var mt = Subject.GetTorrentMetadata(new PutioTorrent { Id = 1 }, new PutioSettings()); + Assert.IsNotNull(mt); + Assert.AreEqual(1, mt.Id); + Assert.IsFalse(mt.Downloaded); + } + + [Test] + public void test_GetTorrentMetadata_returnsExistingObject() + { + ClientGetWillReturn("{\"status\":\"OK\",\"value\":{\"id\":4711,\"downloaded\":true}}"); + + var mt = Subject.GetTorrentMetadata(new PutioTorrent { Id = 1 }, new PutioSettings()); + Assert.IsNotNull(mt); + Assert.AreEqual(4711, mt.Id); + Assert.IsTrue(mt.Downloaded); + } + + [Test] + public void test_GetAllTorrentMetadata_filters_properly() + { + var json = @"{ + ""config"": { + ""sonarr_123"": { + ""downloaded"": true, + ""id"": 123 + }, + ""another_key"": { + ""foo"": ""bar"" + }, + ""sonarr_456"": { + ""downloaded"": true, + ""id"": 456 + } + }, + ""status"": ""OK"" + }"; + ClientGetWillReturn(json); + + var list = Subject.GetAllTorrentMetadata(new PutioSettings()); + Assert.IsTrue(list.ContainsKey("123")); + Assert.IsTrue(list.ContainsKey("456")); + Assert.AreEqual(list.Count, 2); + Assert.IsTrue(list["123"].Downloaded); + Assert.IsTrue(list["456"].Downloaded); + } + + [Test] + public void test_GetFileListingResponse() + { + var json = @"{ + ""cursor"": null, + ""files"": [ + { + ""file_type"": ""VIDEO"", + ""id"": 111, + ""name"": ""My.download.mkv"", + ""parent_id"": 4711 + }, + { + ""file_type"": ""FOLDER"", + ""id"": 222, + ""name"": ""Another-folder[dth]"", + ""parent_id"": 4711 + } + ], + ""parent"": { + ""file_type"": ""FOLDER"", + ""id"": 4711, + ""name"": ""Incoming"", + ""parent_id"": 0 + }, + ""status"": ""OK"", + ""total"": 2 + }"; + ClientGetWillReturn(json); + + var response = Subject.GetFileListingResponse(4711, new PutioSettings()); + + Assert.That(response, Is.Not.Null); + Assert.AreEqual(response.Files.Count, 2); + Assert.AreEqual(4711, response.Parent.Id); + Assert.AreEqual(111, response.Files[0].Id); + Assert.AreEqual(222, response.Files[1].Id); + } + + [Test] + public void test_GetFileListingResponse_empty() + { + var json = @"{ + ""cursor"": null, + ""files"": [], + ""parent"": { + ""file_type"": ""FOLDER"", + ""id"": 4711, + ""name"": ""Incoming"", + ""parent_id"": 0 + }, + ""status"": ""OK"", + ""total"": 0 + }"; + ClientGetWillReturn(json); + + var response = Subject.GetFileListingResponse(4711, new PutioSettings()); + + Assert.That(response, Is.Not.Null); + Assert.AreEqual(response.Files.Count, 0); + } + + private void ClientGetWillReturn(string obj) + where TResult : new() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(new HttpResponse(r, new HttpHeader(), obj))); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Putio/Putio.cs b/src/NzbDrone.Core/Download/Clients/Putio/Putio.cs new file mode 100644 index 000000000..d85ea023f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Putio/Putio.cs @@ -0,0 +1,265 @@ +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.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Putio +{ + public class Putio : TorrentClientBase + { + private readonly IPutioProxy _proxy; + + public Putio( + IPutioProxy 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 "put.io"; + } + } + + protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + { + _proxy.AddTorrentFromUrl(magnetLink, Settings); + return hash; + } + + protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + { + _proxy.AddTorrentFromData(fileContent, Settings); + return hash; + } + + public override IEnumerable GetItems() + { + List torrents; + PutioFileListingResponse fileListingResponse; + + try + { + torrents = _proxy.GetTorrents(Settings); + + if (Settings.SaveParentId.IsNotNullOrWhiteSpace()) + { + fileListingResponse = _proxy.GetFileListingResponse(long.Parse(Settings.SaveParentId), Settings); + } + else + { + fileListingResponse = _proxy.GetFileListingResponse(0, Settings); + } + } + catch (DownloadClientException ex) + { + _logger.Error(ex, ex.Message); + yield break; + } + + foreach (var torrent in torrents) + { + if (torrent.Size == 0) + { + // If totalsize == 0 the torrent is a magnet downloading metadata + continue; + } + + if (Settings.SaveParentId.IsNotNullOrWhiteSpace() && torrent.SaveParentId != long.Parse(Settings.SaveParentId)) + { + // torrent is not related to our parent folder + continue; + } + + var item = new DownloadClientItem + { + DownloadId = torrent.Id.ToString(), + Category = Settings.SaveParentId?.ToString(), + Title = torrent.Name, + TotalSize = torrent.Size, + RemainingSize = torrent.Size - torrent.Downloaded, + DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this), + SeedRatio = torrent.Ratio, + + // Initial status, might change later + Status = GetDownloadItemStatus(torrent) + }; + + try + { + if (torrent.FileId != 0) + { + // Todo: make configurable? Behaviour might be different for users (rclone mount, vs sync/mv) + item.CanMoveFiles = false; + item.CanBeRemoved = false; + + var file = fileListingResponse.Files.FirstOrDefault(f => f.Id == torrent.FileId); + var parent = fileListingResponse.Parent; + + if (file == null || parent == null) + { + item.Message = string.Format("Did not find file {0} in remote listing", torrent.FileId); + item.Status = DownloadItemStatus.Warning; + } + else + { + var expectedPath = new OsPath(Settings.DownloadPath) + new OsPath(parent.Name); + if (file.IsFolder()) + { + expectedPath += new OsPath(file.Name); + } + + if (_diskProvider.FolderExists(expectedPath.FullPath)) + { + item.OutputPath = expectedPath; + } + } + } + } + catch (DownloadClientException ex) + { + _logger.Error(ex, ex.Message); + } + + if (torrent.EstimatedTime >= 0) + { + item.RemainingTime = TimeSpan.FromSeconds(torrent.EstimatedTime); + } + + if (!torrent.ErrorMessage.IsNullOrWhiteSpace()) + { + item.Status = DownloadItemStatus.Warning; + item.Message = torrent.ErrorMessage; + } + + yield return item; + } + } + + private DownloadItemStatus GetDownloadItemStatus(PutioTorrent torrent) + { + if (torrent.Status == PutioTorrentStatus.Completed || + torrent.Status == PutioTorrentStatus.Seeding) + { + return DownloadItemStatus.Completed; + } + + if (torrent.Status == PutioTorrentStatus.InQueue || + torrent.Status == PutioTorrentStatus.Waiting || + torrent.Status == PutioTorrentStatus.PrepareDownload) + { + return DownloadItemStatus.Queued; + } + + if (torrent.Status == PutioTorrentStatus.Error) + { + return DownloadItemStatus.Failed; + } + + return DownloadItemStatus.Downloading; + } + + public override DownloadClientInfo GetStatus() + { + return new DownloadClientInfo + { + IsLocalhost = false + }; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestFolder(Settings.DownloadPath, "DownloadPath")); + failures.AddIfNotNull(TestConnection()); + failures.AddIfNotNull(TestRemoteParentFolder()); + if (failures.Any()) + { + return; + } + + failures.AddIfNotNull(TestGetTorrents()); + } + + private ValidationFailure TestConnection() + { + try + { + _proxy.GetAccountSettings(Settings); + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure("OAuthToken", "Authentication failed") + { + DetailedDescription = "See the wiki for more details on how to obtain an OAuthToken" + }; + } + catch (Exception ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + + private ValidationFailure TestRemoteParentFolder() + { + try + { + _proxy.GetFileListingResponse(long.Parse(Settings.SaveParentId), Settings); + } + catch (Exception ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure("SaveParentId", "This is not a valid folder in your account"); + } + + return null; + } + + public override void RemoveItem(DownloadClientItem item, bool deleteData) + { + throw new NotImplementedException(); + } + + public override void MarkItemAsImported(DownloadClientItem downloadClientItem) + { + // What to do here? Maybe delete the file and transfer from put.io? + base.MarkItemAsImported(downloadClientItem); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Putio/PutioException.cs b/src/NzbDrone.Core/Download/Clients/Putio/PutioException.cs new file mode 100644 index 000000000..69d5e11e7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Putio/PutioException.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Download.Clients.Putio +{ + public class PutioException : DownloadClientException + { + public PutioException(string message) + : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Putio/PutioFile.cs b/src/NzbDrone.Core/Download/Clients/Putio/PutioFile.cs new file mode 100644 index 000000000..973ed8095 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Putio/PutioFile.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Putio +{ + public class PutioFile + { + public static string FILE_TYPE_FOLDER = "FOLDER"; + public static string FILE_TYPE_VIDEO = "VIDEO"; + public long Id { get; set; } + public string Name { get; set; } + [JsonProperty(PropertyName = "parent_id")] + public long ParentId { get; set; } + [JsonProperty(PropertyName = "file_type")] + public string FileType { get; set; } + public bool IsFolder() + { + return FileType == FILE_TYPE_FOLDER; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Putio/PutioProxy.cs b/src/NzbDrone.Core/Download/Clients/Putio/PutioProxy.cs new file mode 100644 index 000000000..81bbb0216 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Putio/PutioProxy.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Download.Clients.Putio +{ + public interface IPutioProxy + { + List GetTorrents(PutioSettings settings); + void AddTorrentFromUrl(string torrentUrl, PutioSettings settings); + void AddTorrentFromData(byte[] torrentData, PutioSettings settings); + void GetAccountSettings(PutioSettings settings); + public PutioTorrentMetadata GetTorrentMetadata(PutioTorrent torrent, PutioSettings settings); + public Dictionary GetAllTorrentMetadata(PutioSettings settings); + public PutioFileListingResponse GetFileListingResponse(long parentId, PutioSettings settings); + } + + public class PutioProxy : IPutioProxy + { + private const string _configPrefix = "sonarr_"; + private readonly Logger _logger; + private readonly IHttpClient _httpClient; + + public PutioProxy(Logger logger, IHttpClient client) + { + _logger = logger; + _httpClient = client; + } + + public List GetTorrents(PutioSettings settings) + { + var result = Execute(BuildRequest(HttpMethod.Get, "transfers/list", settings)); + return result.Resource.Transfers; + } + + public void AddTorrentFromUrl(string torrentUrl, PutioSettings settings) + { + var request = BuildRequest(HttpMethod.Post, "transfers/add", settings); + request.AddFormParameter("url", torrentUrl); + + if (settings.SaveParentId.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("save_parent_id", settings.SaveParentId); + } + + Execute(request); + } + + public void AddTorrentFromData(byte[] torrentData, PutioSettings settings) + { + // var arguments = new Dictionary(); + // arguments.Add("metainfo", Convert.ToBase64String(torrentData)); + // ProcessRequest(Method.POST, "transfers/add", arguments, settings); + } + + public void GetAccountSettings(PutioSettings settings) + { + Execute(BuildRequest(HttpMethod.Get, "account/settings", settings)); + } + + public PutioTorrentMetadata GetTorrentMetadata(PutioTorrent torrent, PutioSettings settings) + { + var metadata = Execute(BuildRequest(HttpMethod.Get, "config/" + _configPrefix + torrent.Id, settings)); + if (metadata.Resource.Value != null) + { + _logger.Debug("Found metadata for torrent: {0} {1}", torrent.Id, metadata.Resource.Value); + return metadata.Resource.Value; + } + + return new PutioTorrentMetadata + { + Id = torrent.Id, + Downloaded = false + }; + } + + public PutioFileListingResponse GetFileListingResponse(long parentId, PutioSettings settings) + { + var request = BuildRequest(HttpMethod.Get, "files/list", settings); + request.AddQueryParam("parent_id", parentId); + request.AddQueryParam("per_page", 1000); + + try + { + var response = Execute(request); + return response.Resource; + } + catch (DownloadClientException ex) + { + _logger.Error(ex, "Failed to get file listing response"); + throw; + } + } + + public Dictionary GetAllTorrentMetadata(PutioSettings settings) + { + var metadata = Execute(BuildRequest(HttpMethod.Get, "config", settings)); + var result = new Dictionary(); + + foreach (var item in metadata.Resource.Config) + { + if (item.Key.StartsWith(_configPrefix)) + { + var torrentId = item.Key.Substring(_configPrefix.Length); + result[torrentId] = item.Value; + } + } + + return result; + } + + private HttpRequestBuilder BuildRequest(HttpMethod method, string endpoint, PutioSettings settings) + { + var requestBuilder = new HttpRequestBuilder("https://api.put.io/v2") + { + LogResponseContent = true + }; + requestBuilder.Method = method; + requestBuilder.Resource(endpoint); + requestBuilder.SetHeader("Authorization", "Bearer " + settings.OAuthToken); + return requestBuilder; + } + + private HttpResponse Execute(HttpRequestBuilder requestBuilder) + where TResult : new() + { + var request = requestBuilder.Build(); + request.LogResponseContent = true; + + try + { + if (requestBuilder.Method == HttpMethod.Post) + { + return _httpClient.Post(request); + } + else + { + return _httpClient.Get(request); + } + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new DownloadClientAuthenticationException("Invalid credentials. Check your OAuthToken"); + } + + throw new DownloadClientException("Failed to connect to put.io API", ex); + } + catch (Exception ex) + { + throw new DownloadClientException("Failed to connect to put.io API", ex); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Putio/PutioResponse.cs b/src/NzbDrone.Core/Download/Clients/Putio/PutioResponse.cs new file mode 100644 index 000000000..e47bd2a33 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Putio/PutioResponse.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Putio +{ + public class PutioGenericResponse + { + [JsonProperty(PropertyName = "error_message")] + public string ErrorMessage { get; set; } + + public string Status { get; set; } + } + + public class PutioTransfersResponse : PutioGenericResponse + { + public List Transfers { get; set; } + } + + public class PutioConfigResponse : PutioGenericResponse + { + public PutioTorrentMetadata Value { get; set; } + } + + public class PutioAllConfigResponse : PutioGenericResponse + { + public Dictionary Config { get; set; } + } + + public class PutioFileListingResponse : PutioGenericResponse + { + public static PutioFileListingResponse Empty() + { + return new PutioFileListingResponse + { + Files = new List(), + Parent = new PutioFile + { + Id = 0, + Name = "Your Files" + } + }; + } + + public List Files { get; set; } + + public PutioFile Parent { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Putio/PutioSettings.cs b/src/NzbDrone.Core/Download/Clients/Putio/PutioSettings.cs new file mode 100644 index 000000000..8654c91f9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Putio/PutioSettings.cs @@ -0,0 +1,49 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.Download.Clients.Putio +{ + public class PutioSettingsValidator : AbstractValidator + { + public PutioSettingsValidator() + { + RuleFor(c => c.OAuthToken).NotEmpty().WithMessage("Please provide an OAuth token"); + RuleFor(c => c.DownloadPath).IsValidPath().WithMessage("Please provide a valid local path"); + RuleFor(c => c.SaveParentId).Matches(@"^\.?[0-9]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters 0-9"); + } + } + + public class PutioSettings : IProviderConfig + { + private static readonly PutioSettingsValidator Validator = new PutioSettingsValidator(); + + public PutioSettings() + { + Url = "https://api.put.io/v2"; + DeleteImported = false; + } + + public string Url { get; } + + [FieldDefinition(0, Label = "OAuth Token", Type = FieldType.Password)] + public string OAuthToken { get; set; } + + [FieldDefinition(1, Label = "Save Parent Folder ID", Type = FieldType.Textbox, HelpText = "Adding a parent folder ID specific to Sonarr avoids conflicts with unrelated non-Sonarr downloads. Using a parent folder is optional, but strongly recommended.")] + public string SaveParentId { get; set; } + + [FieldDefinition(2, Label = "Download Path", Type = FieldType.Path, HelpText = "Path were Sonarr will expect the files to get downloaded to. Note: This client does not download finished transfers automatically. Instead make sure that you download them outside of Sonarr e.g. with rclone")] + public string DownloadPath { get; set; } + + [FieldDefinition(3, Label = "Delete imported files", Type = FieldType.Checkbox, HelpText = "Delete the files on put.io when Sonarr marks them as successfully imported")] + public bool DeleteImported { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Putio/PutioTorrent.cs b/src/NzbDrone.Core/Download/Clients/Putio/PutioTorrent.cs new file mode 100644 index 000000000..196a1080a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Putio/PutioTorrent.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Putio +{ + public class PutioTorrent + { + public int Id { get; set; } + public string Hash { get; set; } + public string Name { get; set; } + + public long Downloaded { get; set; } + public long Uploaded { get; set; } + [JsonProperty(PropertyName = "error_message")] + public string ErrorMessage { get; set; } + [JsonProperty(PropertyName = "estimated_time")] + public long EstimatedTime { get; set; } + [JsonProperty(PropertyName = "file_id")] + public long FileId { get; set; } + [JsonProperty(PropertyName = "percent_done")] + public int PercentDone { get; set; } + [JsonProperty(PropertyName = "seconds_seeding")] + public long SecondsSeeding { get; set; } + public long Size { get; set; } + public string Status { get; set; } + [JsonProperty(PropertyName = "save_parent_id")] + public long SaveParentId { get; set; } + [JsonProperty(PropertyName = "current_ratio")] + public double Ratio { get; set; } + } + + public class PutioTorrentMetadata + { + public static PutioTorrentMetadata fromTorrent(PutioTorrent torrent, bool downloaded = false) + { + return new PutioTorrentMetadata + { + Downloaded = downloaded, + Id = torrent.Id + }; + } + + public bool Downloaded { get; set; } + + public long Id { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Putio/PutioTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/Putio/PutioTorrentStatus.cs new file mode 100644 index 000000000..9abd8d480 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Putio/PutioTorrentStatus.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Download.Clients.Putio +{ + public static class PutioTorrentStatus + { + public static readonly string Waiting = "WAITING"; + public static readonly string PrepareDownload = "PREPARING_DOWNLOAD"; + public static readonly string Completed = "COMPLETED"; + public static readonly string Completing = "COMPLETING"; + public static readonly string Downloading = "DOWNLOADING"; + public static readonly string Error = "ERROR"; + public static readonly string InQueue = "IN_QUEUE"; + public static readonly string Seeding = "SEEDING"; + } +}