diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/DownloadStationFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/DownloadStationFixture.cs new file mode 100644 index 000000000..04b368129 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/DownloadStationFixture.cs @@ -0,0 +1,600 @@ +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.DownloadStation; +using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests +{ + [TestFixture] + public class DownloadStationFixture : DownloadClientFixtureBase + { + protected DownloadStationSettings _settings; + + protected DownloadStationTorrent _queued; + protected DownloadStationTorrent _downloading; + protected DownloadStationTorrent _failed; + protected DownloadStationTorrent _completed; + protected DownloadStationTorrent _seeding; + protected DownloadStationTorrent _magnet; + protected DownloadStationTorrent _singleFile; + protected DownloadStationTorrent _multipleFiles; + protected DownloadStationTorrent _singleFileCompleted; + protected DownloadStationTorrent _multipleFilesCompleted; + + protected string _serialNumber = "SERIALNUMBER"; + protected string _category = "sonarr"; + protected string _tvDirectory = @"video/Series"; + protected string _defaultDestination = "somepath"; + protected OsPath _physicalPath = new OsPath("/mnt/sdb1/mydata"); + + protected Dictionary _downloadStationConfigItems; + + protected string DownloadURL => "magnet:?xt=urn:btih:5dee65101db281ac9c46344cd6b175cdcad53426&dn=download"; + + [SetUp] + public void Setup() + { + _settings = new DownloadStationSettings() + { + Host = "127.0.0.1", + Port = 5000, + Username = "admin", + Password = "pass" + }; + + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = _settings; + + _queued = new DownloadStationTorrent() + { + Id = "id1", + Size = 1000, + Status = DownloadStationTaskStatus.Waiting, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "title", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "0"}, + { "speed_download", "0" } + } + } + }; + + _completed = new DownloadStationTorrent() + { + Id = "id2", + Size = 1000, + Status = DownloadStationTaskStatus.Finished, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "title", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "1000"}, + { "speed_download", "0" } + }, + } + }; + + _seeding = new DownloadStationTorrent() + { + Id = "id2", + Size = 1000, + Status = DownloadStationTaskStatus.Seeding, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "title", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "1000"}, + { "speed_download", "0" } + } + } + }; + + _downloading = new DownloadStationTorrent() + { + Id = "id3", + Size = 1000, + Status = DownloadStationTaskStatus.Downloading, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "title", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "100"}, + { "speed_download", "50" } + } + } + }; + + _failed = new DownloadStationTorrent() + { + Id = "id4", + Size = 1000, + Status = DownloadStationTaskStatus.Error, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "title", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "10"}, + { "speed_download", "0" } + } + } + }; + + _singleFile = new DownloadStationTorrent() + { + Id = "id5", + Size = 1000, + Status = DownloadStationTaskStatus.Seeding, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "a.mkv", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "1000"}, + { "speed_download", "0" } + } + } + }; + + _multipleFiles = new DownloadStationTorrent() + { + Id = "id6", + Size = 1000, + Status = DownloadStationTaskStatus.Seeding, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "title", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "1000"}, + { "speed_download", "0" } + } + } + }; + + _singleFileCompleted = new DownloadStationTorrent() + { + Id = "id6", + Size = 1000, + Status = DownloadStationTaskStatus.Finished, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "a.mkv", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "1000"}, + { "speed_download", "0" } + } + } + }; + + _multipleFilesCompleted = new DownloadStationTorrent() + { + Id = "id6", + Size = 1000, + Status = DownloadStationTaskStatus.Finished, + Type = DownloadStationTaskType.BT, + Username = "admin", + Title = "title", + Additional = new DownloadStationTorrentAdditional + { + Detail = new Dictionary + { + { "destination","shared/folder" }, + { "uri", DownloadURL } + }, + Transfer = new Dictionary + { + { "size_downloaded", "1000"}, + { "speed_download", "0" } + } + } + }; + + 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])); + + _downloadStationConfigItems = new Dictionary + { + { "default_destination", _defaultDestination }, + }; + + Mocker.GetMock() + .Setup(v => v.GetConfig(It.IsAny())) + .Returns(_downloadStationConfigItems); + } + + protected void GivenSharedFolder() + { + Mocker.GetMock() + .Setup(s => s.RemapToFullPath(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((path, setttings, serial) => _physicalPath); + } + + protected void GivenSerialNumber() + { + Mocker.GetMock() + .Setup(s => s.GetSerialNumber(It.IsAny())) + .Returns(_serialNumber); + } + + protected void GivenTvCategory() + { + _settings.TvCategory = _category; + } + + protected void GivenTvDirectory() + { + _settings.TvDirectory = _tvDirectory; + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + Mocker.GetMock() + .Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents); + } + + protected void PrepareClientToReturnQueuedItem() + { + GivenTorrents(new List + { + _queued + }); + } + + 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.AddTorrentFromUrl(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFromData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnQueuedItem); + } + + protected override RemoteEpisode CreateRemoteEpisode() + { + var episode = base.CreateRemoteEpisode(); + + episode.Release.DownloadUrl = DownloadURL; + + return episode; + } + + protected int GivenAllKindOfTasks() + { + var tasks = new List() { _queued, _completed, _failed, _downloading, _seeding }; + + Mocker.GetMock() + .Setup(d => d.GetTorrents(_settings)) + .Returns(tasks); + + return tasks.Count; + } + + [Test] + public void Download_with_TvDirectory_should_force_directory() + { + GivenSerialNumber(); + GivenTvDirectory(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + + Mocker.GetMock() + .Verify(v => v.AddTorrentFromUrl(It.IsAny(), _tvDirectory, It.IsAny()), Times.Once()); + } + + [Test] + public void Download_with_category_should_force_directory() + { + GivenSerialNumber(); + GivenTvCategory(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + + Mocker.GetMock() + .Verify(v => v.AddTorrentFromUrl(It.IsAny(), $"{_defaultDestination}/{_category}", It.IsAny()), Times.Once()); + } + + [Test] + public void Download_without_TvDirectory_and_Category_should_use_default() + { + GivenSerialNumber(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + + Mocker.GetMock() + .Verify(v => v.AddTorrentFromUrl(It.IsAny(), null, It.IsAny()), Times.Once()); + } + + [Test] + public void GetItems_should_ignore_downloads_in_wrong_folder() + { + _settings.TvDirectory = @"/shared/folder/sub"; + + GivenSerialNumber(); + GivenSharedFolder(); + GivenTorrents(new List { _completed }); + + Subject.GetItems().Should().BeEmpty(); + } + + [Test] + public void GetItems_should_throw_if_shared_folder_resolve_fails() + { + Mocker.GetMock() + .Setup(s => s.RemapToFullPath(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new ApplicationException("Some unknown exception, HttpException or DownloadClientException")); + + GivenSerialNumber(); + GivenAllKindOfTasks(); + + Assert.Throws(Is.InstanceOf(), () => Subject.GetItems()); + ExceptionVerification.ExpectedErrors(0); + } + + [Test] + public void GetItems_should_throw_if_serial_number_unavailable() + { + Mocker.GetMock() + .Setup(s => s.GetSerialNumber(_settings)) + .Throws(new ApplicationException("Some unknown exception, HttpException or DownloadClientException")); + + GivenSharedFolder(); + GivenAllKindOfTasks(); + + Assert.Throws(Is.InstanceOf(), () => Subject.GetItems()); + ExceptionVerification.ExpectedErrors(0); + } + + [Test] + public void Download_should_throw_and_not_add_torrent_if_cannot_get_serial_number() + { + var remoteEpisode = CreateRemoteEpisode(); + + Mocker.GetMock() + .Setup(s => s.GetSerialNumber(_settings)) + .Throws(new ApplicationException("Some unknown exception, HttpException or DownloadClientException")); + + Assert.Throws(Is.InstanceOf(), () => Subject.Download(remoteEpisode)); + + Mocker.GetMock() + .Verify(v => v.AddTorrentFromUrl(It.IsAny(), null, _settings), Times.Never()); + } + + [Test] + public void GetItems_should_set_outputPath_to_base_folder_when_single_file_non_finished_torrent() + { + GivenSerialNumber(); + GivenSharedFolder(); + + GivenTorrents(new List() { _singleFile }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().OutputPath.Should().Be(_physicalPath + _singleFile.Title); + } + + [Test] + public void GetItems_should_set_outputPath_to_torrent_folder_when_multiple_files_non_finished_torrent() + { + GivenSerialNumber(); + GivenSharedFolder(); + + GivenTorrents(new List() { _multipleFiles }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().OutputPath.Should().Be(_physicalPath + _multipleFiles.Title); + } + + [Test] + public void GetItems_should_set_outputPath_to_base_folder_when_single_file_finished_torrent() + { + GivenSerialNumber(); + GivenSharedFolder(); + + GivenTorrents(new List() { _singleFileCompleted }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().OutputPath.Should().Be(_physicalPath + _singleFileCompleted.Title); + } + + [Test] + public void GetItems_should_set_outputPath_to_torrent_folder_when_multiple_files_finished_torrent() + { + GivenSerialNumber(); + GivenSharedFolder(); + + GivenTorrents(new List() { _multipleFilesCompleted }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().OutputPath.Should().Be($"{_physicalPath}/{_multipleFiles.Title}"); + } + + [Test] + public void GetItems_should_not_map_outputpath_for_queued_or_downloading_torrents() + { + GivenSerialNumber(); + GivenSharedFolder(); + + GivenTorrents(new List + { + _queued, _downloading + }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(2); + items.Should().OnlyContain(v => v.OutputPath.IsEmpty); + } + + [Test] + public void GetItems_should_map_outputpath_for_completed_or_failed_torrents() + { + GivenSerialNumber(); + GivenSharedFolder(); + + GivenTorrents(new List + { + _completed, _failed, _seeding + }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(3); + items.Should().OnlyContain(v => !v.OutputPath.IsEmpty); + } + + [TestCase(DownloadStationTaskStatus.Downloading, DownloadItemStatus.Downloading, true)] + [TestCase(DownloadStationTaskStatus.Finished, DownloadItemStatus.Completed, false)] + [TestCase(DownloadStationTaskStatus.Seeding, DownloadItemStatus.Completed, true)] + [TestCase(DownloadStationTaskStatus.Waiting, DownloadItemStatus.Queued, true)] + public void GetItems_should_return_readonly_expected(DownloadStationTaskStatus apiStatus, DownloadItemStatus expectedItemStatus, bool readOnlyExpected) + { + GivenSerialNumber(); + GivenSharedFolder(); + + _queued.Status = apiStatus; + + GivenTorrents(new List() { _queued }); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().IsReadOnly.Should().Be(readOnlyExpected); + } + + [TestCase(DownloadStationTaskStatus.Downloading, DownloadItemStatus.Downloading)] + [TestCase(DownloadStationTaskStatus.Error, DownloadItemStatus.Failed)] + [TestCase(DownloadStationTaskStatus.Extracting, DownloadItemStatus.Downloading)] + [TestCase(DownloadStationTaskStatus.Finished, DownloadItemStatus.Completed)] + [TestCase(DownloadStationTaskStatus.Finishing, DownloadItemStatus.Downloading)] + [TestCase(DownloadStationTaskStatus.HashChecking, DownloadItemStatus.Downloading)] + [TestCase(DownloadStationTaskStatus.Paused, DownloadItemStatus.Paused)] + [TestCase(DownloadStationTaskStatus.Seeding, DownloadItemStatus.Completed)] + [TestCase(DownloadStationTaskStatus.Waiting, DownloadItemStatus.Queued)] + public void GetItems_should_return_item_as_downloadItemStatus(DownloadStationTaskStatus apiStatus, DownloadItemStatus expectedItemStatus) + { + GivenSerialNumber(); + GivenSharedFolder(); + + _queued.Status = apiStatus; + + GivenTorrents(new List() { _queued }); + + var items = Subject.GetItems(); + items.Should().HaveCount(1); + + items.First().Status.Should().Be(expectedItemStatus); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/SerialNumberProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/SerialNumberProviderFixture.cs new file mode 100644 index 000000000..3609c9d03 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/SerialNumberProviderFixture.cs @@ -0,0 +1,74 @@ +using System; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Download.Clients.DownloadStation; +using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests +{ + [TestFixture] + public class SerialNumberProviderFixture : CoreTest + { + protected DownloadStationSettings _settings; + + [SetUp] + protected void Setup() + { + _settings = new DownloadStationSettings(); + } + + private void GivenValidResponse() + { + Mocker.GetMock() + .Setup(d => d.GetSerialNumber(It.IsAny())) + .Returns("serial"); + } + + private void GivenInvalidResponse() + { + Mocker.GetMock() + .Setup(d => d.GetSerialNumber(It.IsAny())) + .Throws(new DownloadClientException("Serial response invalid")); + } + + [Test] + public void should_return_hashedserialnumber() + { + GivenValidResponse(); + + var serial = Subject.GetSerialNumber(_settings); + + // This hash should remain the same for 'serial', so don't update the test if you change HashConverter, fix the code instead. + serial.Should().Be("50DE66B735D30738618568294742FCF1DFA52A47"); + + Mocker.GetMock() + .Verify(d => d.GetSerialNumber(It.IsAny()), Times.Once()); + } + + [Test] + public void should_cache_serialnumber() + { + GivenValidResponse(); + + var serial1 = Subject.GetSerialNumber(_settings); + var serial2 = Subject.GetSerialNumber(_settings); + + serial2.Should().Be(serial1); + + Mocker.GetMock() + .Verify(d => d.GetSerialNumber(It.IsAny()), Times.Once()); + } + + [Test] + public void should_throw_if_serial_number_unavailable() + { + Assert.Throws(Is.InstanceOf(), () => Subject.GetSerialNumber(_settings)); + + ExceptionVerification.ExpectedWarns(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/SharedFolderResolverFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/SharedFolderResolverFixture.cs new file mode 100644 index 000000000..a4a814e43 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/SharedFolderResolverFixture.cs @@ -0,0 +1,75 @@ +using System; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Download.Clients.DownloadStation; +using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests +{ + [TestFixture] + public class SharedFolderResolverFixture : CoreTest + { + protected string _serialNumber = "SERIALNUMBER"; + protected OsPath _sharedFolder; + protected OsPath _physicalPath; + protected DownloadStationSettings _settings; + + [SetUp] + protected void Setup() + { + _sharedFolder = new OsPath("/myFolder"); + _physicalPath = new OsPath("/mnt/sda1/folder"); + _settings = new DownloadStationSettings(); + + Mocker.GetMock() + .Setup(f => f.GetSharedFolderMapping(It.IsAny(), It.IsAny())) + .Throws(new DownloadClientException("There is no shared folder")); + + Mocker.GetMock() + .Setup(f => f.GetSharedFolderMapping(_sharedFolder.FullPath, It.IsAny())) + .Returns(new SharedFolderMapping(_sharedFolder.FullPath, _physicalPath.FullPath)); + } + + [Test] + public void should_throw_when_cannot_resolve_shared_folder() + { + Assert.Throws(Is.InstanceOf(), () => Subject.RemapToFullPath(new OsPath("/unknownFolder"), _settings, _serialNumber)); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_return_valid_sharedfolder() + { + var mapping = Subject.RemapToFullPath(_sharedFolder, _settings, "abc"); + + mapping.Should().Be(_physicalPath); + + Mocker.GetMock() + .Verify(f => f.GetSharedFolderMapping(It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public void should_cache_mapping() + { + Subject.RemapToFullPath(_sharedFolder, _settings, "abc"); + Subject.RemapToFullPath(_sharedFolder, _settings, "abc"); + + Mocker.GetMock() + .Verify(f => f.GetSharedFolderMapping(It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public void should_remap_subfolder() + { + var mapping = Subject.RemapToFullPath(_sharedFolder + "sub", _settings, "abc"); + + mapping.Should().Be(_physicalPath + "sub"); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 7dcf7ae33..651c726e5 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -183,6 +183,9 @@ + + + diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApi.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApi.cs new file mode 100644 index 000000000..8fcefdd51 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApi.cs @@ -0,0 +1,12 @@ +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public enum DiskStationApi + { + Info, + Auth, + DownloadStationInfo, + DownloadStationTask, + FileStationList, + DSMInfo, + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs new file mode 100644 index 000000000..c222a258d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs @@ -0,0 +1,24 @@ +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DiskStationApiInfo + { + private string _path; + + public int MaxVersion { get; set; } + + public int MinVersion { get; set; } + + public string Path + { + get { return _path; } + + set + { + if (!string.IsNullOrEmpty(value)) + { + _path = value.TrimStart(new char[] { '/', '\\' }); + } + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStation.cs new file mode 100644 index 000000000..579b3fe04 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStation.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStation : TorrentClientBase + { + protected readonly IDownloadStationProxy _proxy; + protected readonly ISharedFolderResolver _sharedFolderResolver; + protected readonly ISerialNumberProvider _serialNumberProvider; + + public DownloadStation(IDownloadStationProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IRemotePathMappingService remotePathMappingService, + Logger logger, + ICacheManager cacheManager, + ISharedFolderResolver sharedFolderResolver, + ISerialNumberProvider serialNumberProvider) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) + { + _proxy = proxy; + _sharedFolderResolver = sharedFolderResolver; + _serialNumberProvider = serialNumberProvider; + } + + public override string Name => "Download Station"; + + public override IEnumerable GetItems() + { + var torrents = _proxy.GetTorrents(Settings); + var serialNumber = _serialNumberProvider.GetSerialNumber(Settings); + + var items = new List(); + + foreach (var torrent in torrents) + { + var outputPath = new OsPath($"/{torrent.Additional.Detail["destination"]}"); + + if (Settings.TvDirectory.IsNotNullOrWhiteSpace()) + { + if (!new OsPath($"/{Settings.TvDirectory}").Contains(outputPath)) + { + continue; + } + } + else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + { + var directories = outputPath.FullPath.Split('\\', '/'); + if (!directories.Contains(Settings.TvCategory)) + { + continue; + } + } + + var item = new DownloadClientItem() + { + Category = Settings.TvCategory, + DownloadClient = Definition.Name, + DownloadId = CreateDownloadId(torrent.Id, serialNumber), + Title = torrent.Title, + TotalSize = torrent.Size, + RemainingSize = GetRemainingSize(torrent), + RemainingTime = GetRemainingTime(torrent), + Status = GetStatus(torrent), + Message = GetMessage(torrent), + IsReadOnly = !IsFinished(torrent) + }; + + if (item.Status == DownloadItemStatus.Completed || item.Status == DownloadItemStatus.Failed) + { + item.OutputPath = GetOutputPath(outputPath, torrent, serialNumber); + } + + items.Add(item); + } + + return items; + } + + public override DownloadClientStatus GetStatus() + { + try + { + var path = GetDownloadDirectory(); + + return new DownloadClientStatus + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", + OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(path)) } + }; + } + catch (DownloadClientException e) + { + _logger.Debug(e, "Failed to get config from Download Station"); + + throw e; + } + } + + public override void RemoveItem(string downloadId, bool deleteData) + { + try + { + _proxy.RemoveTorrent(ParseDownloadId(downloadId), deleteData, Settings); + _logger.Debug("{0} removed correctly", downloadId); + return; + } + catch (DownloadClientException e) + { + _logger.Error(e); + } + } + + protected OsPath GetOutputPath(OsPath outputPath, DownloadStationTorrent torrent, string serialNumber) + { + var fullPath = _sharedFolderResolver.RemapToFullPath(outputPath, Settings, serialNumber); + + var remotePath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, fullPath); + + var finalPath = remotePath + torrent.Title; + + return finalPath; + } + + protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + { + throw new DownloadClientException("Episodes are not working with Radarr"); + } + + protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + { + throw new DownloadClientException("Episodes are not working with Radarr"); + } + + protected override string AddFromMagnetLink(RemoteMovie remoteEpisode, string hash, string magnetLink) + { + var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); + + _proxy.AddTorrentFromUrl(magnetLink, GetDownloadDirectory(), Settings); + + var item = _proxy.GetTorrents(Settings).SingleOrDefault(t => t.Additional.Detail["uri"] == magnetLink); + + if (item != null) + { + _logger.Debug("{0} added correctly", remoteEpisode); + return CreateDownloadId(item.Id, hashedSerialNumber); + } + + _logger.Debug("No such task {0} in Download Station", magnetLink); + + throw new DownloadClientException("Failed to add magnet task to Download Station"); + } + + protected override string AddFromTorrentFile(RemoteMovie remoteEpisode, string hash, string filename, byte[] fileContent) + { + var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); + + _proxy.AddTorrentFromData(fileContent, filename, GetDownloadDirectory(), Settings); + + var items = _proxy.GetTorrents(Settings).Where(t => t.Additional.Detail["uri"] == Path.GetFileNameWithoutExtension(filename)); + + var item = items.SingleOrDefault(); + + if (item != null) + { + _logger.Debug("{0} added correctly", remoteEpisode); + return CreateDownloadId(item.Id, hashedSerialNumber); + } + + _logger.Debug("No such task {0} in Download Station", filename); + + throw new DownloadClientException("Failed to add torrent task to Download Station"); + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.Any()) return; + failures.AddIfNotNull(TestGetTorrents()); + } + + protected ValidationFailure TestConnection() + { + try + { + return ValidateVersion(); + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure("Username", "Authentication failure") + { + DetailedDescription = $"Please verify your username and password. Also verify if the host running Sonarr isn't blocked from accessing {Name} by WhiteList limitations in the {Name} configuration." + }; + } + catch (WebException ex) + { + _logger.Error(ex); + + if (ex.Status == WebExceptionStatus.ConnectFailure) + { + return new NzbDroneValidationFailure("Host", "Unable to connect") + { + DetailedDescription = "Please verify the hostname and port." + }; + } + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex); + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + } + } + + protected ValidationFailure ValidateVersion() + { + var versionRange = _proxy.GetApiVersion(Settings); + + _logger.Debug("Download Station api version information: Min {0} - Max {1}", versionRange.Min(), versionRange.Max()); + + if (!versionRange.Contains(2)) + { + return new ValidationFailure(string.Empty, $"Download Station API version not supported, should be at least 2. It supports from {versionRange.Min()} to {versionRange.Max()}"); + } + + return null; + } + + protected bool IsFinished(DownloadStationTorrent torrent) + { + return torrent.Status == DownloadStationTaskStatus.Finished; + } + + protected string GetMessage(DownloadStationTorrent torrent) + { + if (torrent.StatusExtra != null) + { + if (torrent.Status == DownloadStationTaskStatus.Extracting) + { + return $"Extracting: {int.Parse(torrent.StatusExtra["unzip_progress"])}%"; + } + + if (torrent.Status == DownloadStationTaskStatus.Error) + { + return torrent.StatusExtra["error_detail"]; + } + } + + return null; + } + + protected DownloadItemStatus GetStatus(DownloadStationTorrent torrent) + { + switch (torrent.Status) + { + case DownloadStationTaskStatus.Waiting: + return torrent.Size == 0 || GetRemainingSize(torrent) > 0 ? DownloadItemStatus.Queued : DownloadItemStatus.Completed; + case DownloadStationTaskStatus.Paused: + return DownloadItemStatus.Paused; + case DownloadStationTaskStatus.Finished: + case DownloadStationTaskStatus.Seeding: + return DownloadItemStatus.Completed; + case DownloadStationTaskStatus.Error: + return DownloadItemStatus.Failed; + } + + return DownloadItemStatus.Downloading; + } + + protected long GetRemainingSize(DownloadStationTorrent torrent) + { + var downloadedString = torrent.Additional.Transfer["size_downloaded"]; + long downloadedSize; + + if (downloadedString.IsNullOrWhiteSpace() || !long.TryParse(downloadedString, out downloadedSize)) + { + _logger.Debug("Torrent {0} has invalid size_downloaded: {1}", torrent.Title, downloadedString); + downloadedSize = 0; + } + + return torrent.Size - Math.Max(0, downloadedSize); + } + + protected TimeSpan? GetRemainingTime(DownloadStationTorrent torrent) + { + var speedString = torrent.Additional.Transfer["speed_download"]; + long downloadSpeed; + + if (speedString.IsNullOrWhiteSpace() || !long.TryParse(speedString, out downloadSpeed)) + { + _logger.Debug("Torrent {0} has invalid speed_download: {1}", torrent.Title, speedString); + downloadSpeed = 0; + } + + if (downloadSpeed <= 0) + { + return null; + } + + var remainingSize = GetRemainingSize(torrent); + + return TimeSpan.FromSeconds(remainingSize / downloadSpeed); + } + + protected ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + return null; + } + catch (Exception ex) + { + return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); + } + } + + protected string ParseDownloadId(string id) + { + return id.Split(':')[1]; + } + + protected string CreateDownloadId(string id, string hashedSerialNumber) + { + return $"{hashedSerialNumber}:{id}"; + } + + protected string GetDefaultDir() + { + var config = _proxy.GetConfig(Settings); + + var path = config["default_destination"] as string; + + return path; + } + + protected string GetDownloadDirectory() + { + if (Settings.TvDirectory.IsNotNullOrWhiteSpace()) + { + return Settings.TvDirectory.TrimStart('/'); + } + else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + { + var destDir = GetDefaultDir(); + + return $"{destDir.TrimEnd('/')}/{Settings.TvCategory}"; + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs new file mode 100644 index 000000000..a1e12d899 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs @@ -0,0 +1,65 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStationSettingsValidator : AbstractValidator + { + public DownloadStationSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + + RuleFor(c => c.TvDirectory).Matches(@"^(?!/).+") + .When(c => c.TvDirectory.IsNotNullOrWhiteSpace()) + .WithMessage("Cannot start with /"); + + RuleFor(c => c.TvCategory).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -"); + + RuleFor(c => c.TvCategory).Empty() + .When(c => c.TvDirectory.IsNotNullOrWhiteSpace()) + .WithMessage("Cannot use Category and Directory"); + } + } + + public class DownloadStationSettings : IProviderConfig + { + private static readonly DownloadStationSettingsValidator Validator = new DownloadStationSettingsValidator(); + + [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 = "Username", Type = FieldType.Textbox)] + public string Username { get; set; } + + [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] + public string Password { get; set; } + + [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] + public string TvCategory { get; set; } + + [FieldDefinition(5, Label = "Directory", Type = FieldType.Textbox, HelpText = "Optional shared folder to put downloads into, leave blank to use the default Download Station location")] + public string TvDirectory { get; set; } + + [FieldDefinition(6, Label = "Use SSL", Type = FieldType.Checkbox)] + public bool UseSsl { get; set; } + + public DownloadStationSettings() + { + this.Host = "127.0.0.1"; + this.Port = 5000; + } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrent.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrent.cs new file mode 100644 index 000000000..e840fddb0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrent.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStationTorrent + { + public string Username { get; set; } + + public string Id { get; set; } + + public string Title { get; set; } + + public long Size { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + public DownloadStationTaskType Type { get; set; } + + [JsonProperty(PropertyName = "status_extra")] + public Dictionary StatusExtra { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + public DownloadStationTaskStatus Status { get; set; } + + public DownloadStationTorrentAdditional Additional { get; set; } + + public override string ToString() + { + return this.Title; + } + } + + public enum DownloadStationTaskType + { + BT, NZB, http, ftp, eMule + } + + public enum DownloadStationTaskStatus + { + Waiting, + Downloading, + Paused, + Finishing, + Finished, + HashChecking, + Seeding, + FileHostingWaiting, + Extracting, + Error + } + + public enum DownloadStationPriority + { + Auto, + Low, + Normal, + High + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrentAdditional.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrentAdditional.cs new file mode 100644 index 000000000..f10c84b00 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrentAdditional.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStationTorrentAdditional + { + public Dictionary Detail { get; set; } + + public Dictionary Transfer { get; set; } + + [JsonProperty("File")] + public List Files { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrentFile.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrentFile.cs new file mode 100644 index 000000000..186911c77 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTorrentFile.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using static NzbDrone.Core.Download.Clients.DownloadStation.DownloadStationTorrent; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStationTorrentFile + { + public string FileName { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + public DownloadStationPriority Priority { get; set; } + + [JsonProperty("size")] + public long TotalSize { get; set; } + + [JsonProperty("size_downloaded")] + public long BytesDownloaded { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs new file mode 100644 index 000000000..2d8bc75e4 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Clients.DownloadStation.Responses; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IDSMInfoProxy + { + string GetSerialNumber(DownloadStationSettings settings); + } + + public class DSMInfoProxy : DiskStationProxyBase, IDSMInfoProxy + { + public DSMInfoProxy(IHttpClient httpClient, Logger logger) : + base(httpClient, logger) + { + } + + public string GetSerialNumber(DownloadStationSettings settings) + { + var arguments = new Dictionary() { + { "api", "SYNO.DSM.Info" }, + { "version", "2" }, + { "method", "getinfo" } + }; + + var response = ProcessRequest(DiskStationApi.DSMInfo, arguments, settings, "get serial number"); + return response.Data.SerialNumber; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs new file mode 100644 index 000000000..e64ccf4a7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs @@ -0,0 +1,213 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.DownloadStation.Responses; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public abstract class DiskStationProxyBase + { + private static readonly Dictionary Resources; + + private readonly IHttpClient _httpClient; + protected readonly Logger _logger; + private bool _authenticated; + + static DiskStationProxyBase() + { + Resources = new Dictionary + { + { DiskStationApi.Info, "query.cgi" } + }; + } + + public DiskStationProxyBase(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + + protected DiskStationResponse ProcessRequest(DiskStationApi api, + Dictionary arguments, + DownloadStationSettings settings, + string operation, + HttpMethod method = HttpMethod.GET) + { + return ProcessRequest(api, arguments, settings, operation, method); + } + + protected DiskStationResponse ProcessRequest(DiskStationApi api, + Dictionary arguments, + DownloadStationSettings settings, + string operation, + HttpMethod method = HttpMethod.GET, + int retries = 0) where T : new() + { + if (retries == 5) + { + throw new DownloadClientException("Try to process same request more than 5 times"); + } + + if (!_authenticated && api != DiskStationApi.Info && api != DiskStationApi.DSMInfo) + { + AuthenticateClient(settings); + } + + var request = BuildRequest(settings, api, arguments, method); + var response = _httpClient.Execute(request); + + _logger.Debug("Trying to {0}", operation); + + if (response.StatusCode == HttpStatusCode.OK) + { + var responseContent = Json.Deserialize>(response.Content); + + if (responseContent.Success) + { + return responseContent; + } + else + { + if (responseContent.Error.SessionError) + { + _authenticated = false; + return ProcessRequest(api, arguments, settings, operation, method, retries++); + } + + var msg = $"Failed to {operation}. Reason: {responseContent.Error.GetMessage(api)}"; + _logger.Error(msg); + + throw new DownloadClientException(msg); + } + } + else + { + throw new HttpException(request, response); + } + } + + private void AuthenticateClient(DownloadStationSettings settings) + { + var arguments = new Dictionary + { + { "api", "SYNO.API.Auth" }, + { "version", "1" }, + { "method", "login" }, + { "account", settings.Username }, + { "passwd", settings.Password }, + { "format", "cookie" }, + { "session", "DownloadStation" }, + }; + + var authLoginRequest = BuildRequest(settings, DiskStationApi.Auth, arguments, HttpMethod.GET); + authLoginRequest.StoreResponseCookie = true; + + var response = _httpClient.Execute(authLoginRequest); + + var downloadStationResponse = Json.Deserialize>(response.Content); + + var authResponse = Json.Deserialize>(response.Content); + + _authenticated = authResponse.Success; + + if (!_authenticated) + { + throw new DownloadClientAuthenticationException(downloadStationResponse.Error.GetMessage(DiskStationApi.Auth)); + } + } + + private HttpRequest BuildRequest(DownloadStationSettings settings, DiskStationApi api, Dictionary arguments, HttpMethod method) + { + if (!Resources.ContainsKey(api)) + { + GetApiVersion(settings, api); + } + + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port).Resource($"webapi/{Resources[api]}"); + requestBuilder.Method = method; + requestBuilder.LogResponseContent = true; + requestBuilder.SuppressHttpError = true; + requestBuilder.AllowAutoRedirect = false; + + if (requestBuilder.Method == HttpMethod.POST) + { + if (api == DiskStationApi.DownloadStationTask && arguments.ContainsKey("file")) + { + requestBuilder.Headers.ContentType = "multipart/form-data"; + + foreach (var arg in arguments) + { + if (arg.Key == "file") + { + Dictionary file = (Dictionary)arg.Value; + requestBuilder.AddFormUpload(arg.Key, file["name"].ToString(), (byte[])file["data"]); + } + else + { + requestBuilder.AddFormParameter(arg.Key, arg.Value); + } + } + } + else + { + requestBuilder.Headers.ContentType = "application/json"; + } + } + else + { + foreach (var arg in arguments) + { + requestBuilder.AddQueryParam(arg.Key, arg.Value); + } + } + + return requestBuilder.Build(); + } + + protected IEnumerable GetApiVersion(DownloadStationSettings settings, DiskStationApi api) + { + var arguments = new Dictionary + { + { "api", "SYNO.API.Info" }, + { "version", "1" }, + { "method", "query" }, + { "query", "SYNO.API.Auth, SYNO.DownloadStation.Info, SYNO.DownloadStation.Task, SYNO.FileStation.List, SYNO.DSM.Info" }, + }; + + var infoResponse = ProcessRequest(DiskStationApi.Info, arguments, settings, "Get api version"); + + //TODO: Refactor this into more elegant code + var infoResponeDSAuth = infoResponse.Data["SYNO.API.Auth"]; + var infoResponeDSInfo = infoResponse.Data["SYNO.DownloadStation.Info"]; + var infoResponeDSTask = infoResponse.Data["SYNO.DownloadStation.Task"]; + var infoResponseFSList = infoResponse.Data["SYNO.FileStation.List"]; + var infoResponseDSMInfo = infoResponse.Data["SYNO.DSM.Info"]; + + Resources[DiskStationApi.Auth] = infoResponeDSAuth.Path; + Resources[DiskStationApi.DownloadStationInfo] = infoResponeDSInfo.Path; + Resources[DiskStationApi.DownloadStationTask] = infoResponeDSTask.Path; + Resources[DiskStationApi.FileStationList] = infoResponseFSList.Path; + Resources[DiskStationApi.DSMInfo] = infoResponseDSMInfo.Path; + + switch (api) + { + case DiskStationApi.Auth: + return Enumerable.Range(infoResponeDSAuth.MinVersion, infoResponeDSAuth.MaxVersion - infoResponeDSAuth.MinVersion + 1); + case DiskStationApi.DownloadStationInfo: + return Enumerable.Range(infoResponeDSInfo.MinVersion, infoResponeDSInfo.MaxVersion - infoResponeDSInfo.MinVersion + 1); + case DiskStationApi.DownloadStationTask: + return Enumerable.Range(infoResponeDSTask.MinVersion, infoResponeDSTask.MaxVersion - infoResponeDSTask.MinVersion + 1); + case DiskStationApi.FileStationList: + return Enumerable.Range(infoResponseFSList.MinVersion, infoResponseFSList.MaxVersion - infoResponseFSList.MinVersion + 1); + case DiskStationApi.DSMInfo: + return Enumerable.Range(infoResponseDSMInfo.MinVersion, infoResponseDSMInfo.MaxVersion - infoResponseDSMInfo.MinVersion + 1); + default: + throw new DownloadClientException("Api not implemented"); + } + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationProxy.cs new file mode 100644 index 000000000..8da818408 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationProxy.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Clients.DownloadStation.Responses; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IDownloadStationProxy + { + IEnumerable GetTorrents(DownloadStationSettings settings); + Dictionary GetConfig(DownloadStationSettings settings); + void RemoveTorrent(string downloadId, bool deleteData, DownloadStationSettings settings); + void AddTorrentFromUrl(string url, string downloadDirectory, DownloadStationSettings settings); + void AddTorrentFromData(byte[] torrentData, string filename, string downloadDirectory, DownloadStationSettings settings); + IEnumerable GetApiVersion(DownloadStationSettings settings); + } + + public class DownloadStationProxy : DiskStationProxyBase, IDownloadStationProxy + { + public DownloadStationProxy(IHttpClient httpClient, Logger logger) + : base(httpClient, logger) + { + } + + public void AddTorrentFromData(byte[] torrentData, string filename, string downloadDirectory, DownloadStationSettings settings) + { + var arguments = new Dictionary + { + { "api", "SYNO.DownloadStation.Task" }, + { "version", "2" }, + { "method", "create" } + }; + + if (downloadDirectory.IsNotNullOrWhiteSpace()) + { + arguments.Add("destination", downloadDirectory); + } + + arguments.Add("file", new Dictionary() { { "name", filename }, { "data", torrentData } }); + + var response = ProcessRequest(DiskStationApi.DownloadStationTask, arguments, settings, $"add torrent from data {filename}", HttpMethod.POST); + } + + public void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, DownloadStationSettings settings) + { + var arguments = new Dictionary + { + { "api", "SYNO.DownloadStation.Task" }, + { "version", "3" }, + { "method", "create" }, + { "uri", torrentUrl } + }; + + if (downloadDirectory.IsNotNullOrWhiteSpace()) + { + arguments.Add("destination", downloadDirectory); + } + + var response = ProcessRequest(DiskStationApi.DownloadStationTask, arguments, settings, $"add torrent from url {torrentUrl}", HttpMethod.GET); + } + + public IEnumerable GetTorrents(DownloadStationSettings settings) + { + var arguments = new Dictionary + { + { "api", "SYNO.DownloadStation.Task" }, + { "version", "1" }, + { "method", "list" }, + { "additional", "detail,transfer" } + }; + + try + { + var response = ProcessRequest(DiskStationApi.DownloadStationTask, arguments, settings, "get torrents"); + + return response.Data.Tasks.Where(t => t.Type == DownloadStationTaskType.BT); + } + catch (DownloadClientException e) + { + _logger.Error(e); + return new List(); + } + } + + public Dictionary GetConfig(DownloadStationSettings settings) + { + var arguments = new Dictionary + { + { "api", "SYNO.DownloadStation.Info" }, + { "version", "1" }, + { "method", "getconfig" } + }; + + var response = ProcessRequest>(DiskStationApi.DownloadStationInfo, arguments, settings, "get config"); + + return response.Data; + } + + public void RemoveTorrent(string downloadId, bool deleteData, DownloadStationSettings settings) + { + var arguments = new Dictionary + { + { "api", "SYNO.DownloadStation.Task" }, + { "version", "1" }, + { "method", "delete" }, + { "id", downloadId }, + { "force_complete", false } + }; + + var response = ProcessRequest(DiskStationApi.DownloadStationTask, arguments, settings, $"remove item {downloadId}"); + _logger.Trace("Item {0} removed from Download Station", downloadId); + } + + public IEnumerable GetApiVersion(DownloadStationSettings settings) + { + return base.GetApiVersion(settings, DiskStationApi.DownloadStationInfo); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs new file mode 100644 index 000000000..e3476df87 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.DownloadStation.Responses; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IFileStationProxy + { + SharedFolderMapping GetSharedFolderMapping(string sharedFolder, DownloadStationSettings settings); + IEnumerable GetApiVersion(DownloadStationSettings settings); + FileStationListFileInfoResponse GetInfoFileOrDirectory(string path, DownloadStationSettings settings); + } + + public class FileStationProxy : DiskStationProxyBase, IFileStationProxy + { + public FileStationProxy(IHttpClient httpClient, Logger logger) + : base(httpClient, logger) + { + } + + public IEnumerable GetApiVersion(DownloadStationSettings settings) + { + return base.GetApiVersion(settings, DiskStationApi.FileStationList); + } + + public SharedFolderMapping GetSharedFolderMapping(string sharedFolder, DownloadStationSettings settings) + { + var info = GetInfoFileOrDirectory(sharedFolder, settings); + + var physicalPath = info.Additional["real_path"].ToString(); + + return new SharedFolderMapping(sharedFolder, physicalPath); + } + + public FileStationListFileInfoResponse GetInfoFileOrDirectory(string path, DownloadStationSettings settings) + { + var arguments = new Dictionary + { + { "api", "SYNO.FileStation.List" }, + { "version", "2" }, + { "method", "getinfo" }, + { "path", new [] { path }.ToJson() }, + { "additional", $"[\"real_path\"]" } + }; + + var response = ProcessRequest(DiskStationApi.FileStationList, arguments, settings, $"get info of {path}"); + + return response.Data.Files.First(); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DSMInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DSMInfoResponse.cs new file mode 100644 index 000000000..0848bba70 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DSMInfoResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class DSMInfoResponse + { + [JsonProperty("serial")] + public string SerialNumber { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationAuthResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationAuthResponse.cs new file mode 100644 index 000000000..d02503a25 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationAuthResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class DiskStationAuthResponse + { + public string SId { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs new file mode 100644 index 000000000..af818313e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class DiskStationError + { + private static readonly Dictionary CommonMessages; + private static readonly Dictionary AuthMessages; + private static readonly Dictionary DownloadStationTaskMessages; + private static readonly Dictionary FileStationMessages; + + static DiskStationError() + { + CommonMessages = new Dictionary + { + { 100, "Unknown error" }, + { 101, "Invalid parameter" }, + { 102, "The requested API does not exist" }, + { 103, "The requested method does not exist" }, + { 104, "The requested version does not support the functionality" }, + { 105, "The logged in session does not have permission" }, + { 106, "Session timeout" }, + { 107, "Session interrupted by duplicate login" } + }; + + AuthMessages = new Dictionary + { + { 400, "No such account or incorrect password" }, + { 401, "Account disabled" }, + { 402, "Permission denied" }, + { 403, "2-step verification code required" }, + { 404, "Failed to authenticate 2-step verification code" } + }; + + DownloadStationTaskMessages = new Dictionary + { + { 400, "File upload failed" }, + { 401, "Max number of tasks reached" }, + { 402, "Destination denied" }, + { 403, "Destination does not exist" }, + { 404, "Invalid task id" }, + { 405, "Invalid task action" }, + { 406, "No default destination" }, + { 407, "Set destination failed" }, + { 408, "File does not exist" } + }; + + FileStationMessages = new Dictionary + { + { 400, "Invalid parameter of file operation" }, + { 401, "Unknown error of file operation" }, + { 402, "System is too busy" }, + { 403, "Invalid user does this file operation" }, + { 404, "Invalid group does this file operation" }, + { 405, "Invalid user and group does this file operation" }, + { 406, "Can’t get user/group information from the account server" }, + { 407, "Operation not permitted" }, + { 408, "No such file or directory" }, + { 409, "Non-supported file system" }, + { 410, "Failed to connect internet-based file system (ex: CIFS)" }, + { 411, "Read-only file system" }, + { 412, "Filename too long in the non-encrypted file system" }, + { 413, "Filename too long in the encrypted file system" }, + { 414, "File already exists" }, + { 415, "Disk quota exceeded" }, + { 416, "No space left on device" }, + { 417, "Input/output error" }, + { 418, "Illegal name or path" }, + { 419, "Illegal file name" }, + { 420, "Illegal file name on FAT file system" }, + { 421, "Device or resource busy" }, + { 599, "No such task of the file operation" }, + }; + } + + public int Code { get; set; } + + public bool SessionError => Code == 105 || Code == 106 || Code == 107; + + public string GetMessage(DiskStationApi api) + { + if (api == DiskStationApi.Auth && AuthMessages.ContainsKey(Code)) + { + return AuthMessages[Code]; + } + if (api == DiskStationApi.DownloadStationTask && DownloadStationTaskMessages.ContainsKey(Code)) + { + return DownloadStationTaskMessages[Code]; + } + if (api == DiskStationApi.FileStationList && FileStationMessages.ContainsKey(Code)) + { + return FileStationMessages[Code]; + } + + return CommonMessages[Code]; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs new file mode 100644 index 000000000..54ac7dc8b --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class DiskStationApiInfoResponse : Dictionary + { + + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs new file mode 100644 index 000000000..de7aadfc6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs @@ -0,0 +1,11 @@ +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class DiskStationResponse where T:new() + { + public bool Success { get; set; } + + public DiskStationError Error { get; set; } + + public T Data { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStationTaskInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStationTaskInfoResponse.cs new file mode 100644 index 000000000..1da16621c --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStationTaskInfoResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class DownloadStationTaskInfoResponse + { + public int Offset { get; set; } + public List Tasks {get;set;} + public int Total { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListFileInfoResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListFileInfoResponse.cs new file mode 100644 index 000000000..2f689a48b --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListFileInfoResponse.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class FileStationListFileInfoResponse + { + public bool IsDir { get; set; } + public string Name { get; set; } + public string Path { get; set; } + public Dictionary Additional { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListResponse.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListResponse.cs new file mode 100644 index 000000000..e12c60094 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses +{ + public class FileStationListResponse + { + public List Files { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/SerialNumberProvider.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/SerialNumberProvider.cs new file mode 100644 index 000000000..ddf971384 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/SerialNumberProvider.cs @@ -0,0 +1,49 @@ +using System; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Crypto; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public interface ISerialNumberProvider + { + string GetSerialNumber(DownloadStationSettings settings); + } + + public class SerialNumberProvider : ISerialNumberProvider + { + private readonly IDSMInfoProxy _proxy; + private ICached _cache; + private readonly ILogger _logger; + + public SerialNumberProvider(ICacheManager cacheManager, + IDSMInfoProxy proxy, + Logger logger) + { + _proxy = proxy; + _cache = cacheManager.GetCache(GetType()); + _logger = logger; + } + + public string GetSerialNumber(DownloadStationSettings settings) + { + try + { + return _cache.Get(settings.Host, () => GetHashedSerialNumber(settings), TimeSpan.FromMinutes(5)); + } + catch (Exception ex) + { + _logger.Warn(ex, "Could not get the serial number from Download Station {0}:{1}", settings.Host, settings.Port); + throw; + } + } + + private string GetHashedSerialNumber(DownloadStationSettings settings) + { + var serialNumber = _proxy.GetSerialNumber(settings); + return HashConverter.GetHash(serialNumber).ToHexString(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderMapping.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderMapping.cs new file mode 100644 index 000000000..15946e861 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderMapping.cs @@ -0,0 +1,21 @@ +using NzbDrone.Common.Disk; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class SharedFolderMapping + { + public OsPath PhysicalPath { get; private set; } + public OsPath SharedFolder { get; private set; } + + public SharedFolderMapping(string sharedFolder, string physicalPath) + { + SharedFolder = new OsPath(sharedFolder); + PhysicalPath = new OsPath(physicalPath); + } + + public override string ToString() + { + return $"{SharedFolder} -> {PhysicalPath}"; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderResolver.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderResolver.cs new file mode 100644 index 000000000..d1db18db0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderResolver.cs @@ -0,0 +1,55 @@ +using System; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public interface ISharedFolderResolver + { + OsPath RemapToFullPath(OsPath sharedFolderPath, DownloadStationSettings settings, string serialNumber); + } + + public class SharedFolderResolver : ISharedFolderResolver + { + private readonly IFileStationProxy _proxy; + private ICached _cache; + private readonly ILogger _logger; + + public SharedFolderResolver(ICacheManager cacheManager, + IFileStationProxy proxy, + Logger logger) + { + _proxy = proxy; + _cache = cacheManager.GetCache(GetType()); + _logger = logger; + } + + private SharedFolderMapping GetPhysicalPath(OsPath sharedFolder, DownloadStationSettings settings) + { + try + { + return _proxy.GetSharedFolderMapping(sharedFolder.FullPath, settings); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to get shared folder {0} from Disk Station {1}:{2}", sharedFolder, settings.Host, settings.Port); + + throw; + } + } + + public OsPath RemapToFullPath(OsPath sharedFolderPath, DownloadStationSettings settings, string serialNumber) + { + var index = sharedFolderPath.FullPath.IndexOf('/', 1); + var sharedFolder = index == -1 ? sharedFolderPath : new OsPath(sharedFolderPath.FullPath.Substring(0, index)); + + var mapping = _cache.Get($"{serialNumber}:{sharedFolder}", () => GetPhysicalPath(sharedFolder, settings), TimeSpan.FromHours(1)); + + var fullPath = mapping.PhysicalPath + (sharedFolderPath - mapping.SharedFolder); + + return fullPath; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs index b04d80e81..a1e869ad6 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -39,12 +39,12 @@ namespace NzbDrone.Core.Download.Clients.RTorrent protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) { - throw new NotImplementedException("Episodes are not working with Radarr"); + throw new DownloadClientException("Episodes are not working with Radarr"); } protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) { - throw new NotImplementedException("Episodes are not working with Radarr"); + throw new DownloadClientException("Episodes are not working with Radarr"); } protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink) diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index e72140641..5b4fc58f2 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -413,6 +413,28 @@ + + + + + + + + + + + + + + + + + + + + + +