From 881313ef2bd81014dfb4598c4721ee4c2629d6a1 Mon Sep 17 00:00:00 2001 From: Qstick Date: Thu, 18 Mar 2021 00:07:25 -0400 Subject: [PATCH] New: Download Clients for Manual Grabs --- frontend/src/App/AppRoutes.js | 6 + .../Components/Page/Sidebar/PageSidebar.js | 4 + .../Indexer/Index/Table/IndexerIndexRow.js | 1 - frontend/src/Search/Table/SearchIndexRow.js | 76 ++- frontend/src/Search/Table/SearchIndexTable.js | 7 +- .../Search/Table/SearchIndexTableConnector.js | 13 +- .../DownloadClients/DownloadClientSettings.js | 92 ++++ .../DownloadClientSettingsConnector.js | 21 + .../DownloadClients/AddDownloadClientItem.css | 44 ++ .../DownloadClients/AddDownloadClientItem.js | 111 +++++ .../DownloadClients/AddDownloadClientModal.js | 25 + .../AddDownloadClientModalContent.css | 5 + .../AddDownloadClientModalContent.js | 122 +++++ .../AddDownloadClientModalContentConnector.js | 75 +++ .../AddDownloadClientPresetMenuItem.js | 49 ++ .../DownloadClients/DownloadClient.css | 19 + .../DownloadClients/DownloadClient.js | 126 +++++ .../DownloadClients/DownloadClients.css | 20 + .../DownloadClients/DownloadClients.js | 115 +++++ .../DownloadClientsConnector.js | 56 +++ .../EditDownloadClientModal.js | 27 ++ .../EditDownloadClientModalConnector.js | 65 +++ .../EditDownloadClientModalContent.css | 11 + .../EditDownloadClientModalContent.js | 197 ++++++++ ...EditDownloadClientModalContentConnector.js | 88 ++++ frontend/src/Settings/Settings.js | 11 + .../Store/Actions/Settings/downloadClients.js | 117 +++++ frontend/src/Store/Actions/releaseActions.js | 2 +- frontend/src/Store/Actions/settingsActions.js | 5 + src/NzbDrone.Core/Datastore/TableMapping.cs | 8 + .../Download/Clients/Deluge/Deluge.cs | 196 ++++++++ .../Download/Clients/Deluge/DelugeError.cs | 8 + .../Clients/Deluge/DelugeException.cs | 13 + .../Download/Clients/Deluge/DelugeLabel.cs | 21 + .../Download/Clients/Deluge/DelugePriority.cs | 8 + .../Download/Clients/Deluge/DelugeProxy.cs | 371 ++++++++++++++ .../Download/Clients/Deluge/DelugeSettings.cs | 60 +++ .../Download/Clients/Deluge/DelugeTorrent.cs | 55 +++ .../Clients/Deluge/DelugeTorrentStatus.cs | 12 + .../Clients/Deluge/DelugeUpdateUIResult.cs | 11 + .../DownloadClientAuthenticationException.cs | 27 ++ .../Clients/DownloadClientException.cs | 28 ++ .../DownloadClientUnavailableException.cs | 27 ++ .../Clients/DownloadStation/DiskStationApi.cs | 12 + .../DownloadStation/DiskStationApiInfo.cs | 33 ++ .../DownloadStationSettings.cs | 65 +++ .../DownloadStation/DownloadStationTask.cs | 69 +++ .../DownloadStationTaskAdditional.cs | 15 + .../DownloadStationTaskFile.cs | 19 + .../DownloadStation/Proxies/DSMInfoProxy.cs | 31 ++ .../Proxies/DiskStationProxyBase.cs | 252 ++++++++++ .../Proxies/DownloadStationInfoProxy.cs | 29 ++ .../Proxies/DownloadStationTaskProxy.cs | 79 +++ .../Proxies/FileStationProxy.cs | 44 ++ .../Responses/DSMInfoResponse.cs | 10 + .../Responses/DiskStationAuthResponse.cs | 7 + .../Responses/DiskStationError.cs | 106 ++++ .../Responses/DiskStationInfoResponse.cs | 8 + .../Responses/DiskStationResponse.cs | 12 + .../DownloadStationTaskInfoResponse.cs | 11 + .../FileStationListFileInfoResponse.cs | 12 + .../Responses/FileStationListResponse.cs | 9 + .../DownloadStation/SerialNumberProvider.cs | 49 ++ .../DownloadStation/SharedFolderMapping.cs | 21 + .../DownloadStation/SharedFolderResolver.cs | 55 +++ .../DownloadStation/TorrentDownloadStation.cs | 329 +++++++++++++ .../DownloadStation/UsenetDownloadStation.cs | 291 +++++++++++ .../Download/Clients/Flood/Flood.cs | 95 ++++ .../Download/Clients/Flood/FloodProxy.cs | 213 ++++++++ .../Download/Clients/Flood/FloodSettings.cs | 72 +++ .../Clients/Flood/Models/AdditionalTags.cs | 28 ++ .../Download/Clients/Flood/Types/Torrent.cs | 35 ++ .../Clients/Flood/Types/TorrentContent.cs | 10 + .../Clients/Flood/Types/TorrentListSummary.cs | 14 + .../Download/Clients/Hadouken/Hadouken.cs | 106 ++++ .../Clients/Hadouken/HadoukenProxy.cs | 183 +++++++ .../Clients/Hadouken/HadoukenSettings.cs | 62 +++ .../Hadouken/Models/HadoukenSystemInfo.cs | 11 + .../Hadouken/Models/HadoukenTorrent.cs | 20 + .../Models/HadoukenTorrentResponse.cs | 7 + .../Hadouken/Models/HadoukenTorrentState.cs | 16 + .../NzbVortexLoginResultTypeConverter.cs | 29 ++ .../NzbVortexResultTypeConverter.cs | 29 ++ .../Download/Clients/NzbVortex/NzbVortex.cs | 137 ++++++ .../NzbVortexAuthenticationException.cs | 27 ++ .../Clients/NzbVortex/NzbVortexFile.cs | 16 + .../Clients/NzbVortex/NzbVortexGroup.cs | 7 + .../Clients/NzbVortex/NzbVortexJsonError.cs | 13 + .../NzbVortex/NzbVortexLoginResultType.cs | 8 + .../NzbVortexNotLoggedInException.cs | 32 ++ .../Clients/NzbVortex/NzbVortexPriority.cs | 9 + .../Clients/NzbVortex/NzbVortexProxy.cs | 218 +++++++++ .../Clients/NzbVortex/NzbVortexQueueItem.cs | 23 + .../Clients/NzbVortex/NzbVortexResultType.cs | 9 + .../Clients/NzbVortex/NzbVortexSettings.cs | 61 +++ .../Clients/NzbVortex/NzbVortexStateType.cs | 31 ++ .../Responses/NzbVortexAddResponse.cs | 10 + .../Responses/NzbVortexApiVersionResponse.cs | 7 + .../Responses/NzbVortexAuthNonceResponse.cs | 7 + .../Responses/NzbVortexAuthResponse.cs | 13 + .../Responses/NzbVortexFilesResponse.cs | 9 + .../Responses/NzbVortexGroupResponse.cs | 9 + .../Responses/NzbVortexQueueResponse.cs | 11 + .../Responses/NzbVortexResponseBase.cs | 11 + .../Responses/NzbVortexRetryResponse.cs | 12 + .../Responses/NzbVortexVersionResponse.cs | 7 + .../Download/Clients/Nzbget/ErrorModel.cs | 14 + .../Download/Clients/Nzbget/JsonError.cs | 8 + .../Download/Clients/Nzbget/Nzbget.cs | 181 +++++++ .../Download/Clients/Nzbget/NzbgetCategory.cs | 11 + .../Clients/Nzbget/NzbgetConfigItem.cs | 8 + .../Clients/Nzbget/NzbgetGlobalStatus.cs | 14 + .../Clients/Nzbget/NzbgetHistoryItem.cs | 22 + .../Clients/Nzbget/NzbgetParameter.cs | 8 + .../Clients/Nzbget/NzbgetPostQueueItem.cs | 14 + .../Download/Clients/Nzbget/NzbgetPriority.cs | 12 + .../Download/Clients/Nzbget/NzbgetProxy.cs | 278 +++++++++++ .../Clients/Nzbget/NzbgetQueueItem.cs | 23 + .../Download/Clients/Nzbget/NzbgetResponse.cs | 9 + .../Download/Clients/Nzbget/NzbgetSettings.cs | 70 +++ .../Download/Clients/Pneumatic/Pneumatic.cs | 81 ++++ .../Clients/Pneumatic/PneumaticSettings.cs | 33 ++ .../Clients/QBittorrent/QBittorrent.cs | 458 ++++++++++++++++++ .../Clients/QBittorrent/QBittorrentLabel.cs | 8 + .../QBittorrent/QBittorrentPreferences.cs | 40 ++ .../QBittorrent/QBittorrentPriority.cs | 8 + .../QBittorrent/QBittorrentProxySelector.cs | 103 ++++ .../Clients/QBittorrent/QBittorrentProxyV1.cs | 387 +++++++++++++++ .../Clients/QBittorrent/QBittorrentProxyV2.cs | 433 +++++++++++++++++ .../QBittorrent/QBittorrentSettings.cs | 64 +++ .../Clients/QBittorrent/QBittorrentState.cs | 9 + .../Clients/QBittorrent/QBittorrentTorrent.cs | 57 +++ .../SabnzbdPriorityTypeConverter.cs | 29 ++ .../SabnzbdQueueTimeConverter.cs | 35 ++ .../SabnzbdStringArrayConverter.cs | 46 ++ .../Sabnzbd/Responses/SabnzbdAddResponse.cs | 18 + .../Responses/SabnzbdCategoryResponse.cs | 14 + .../Responses/SabnzbdConfigResponse.cs | 7 + .../Responses/SabnzbdFullStatusResponse.cs | 7 + .../Sabnzbd/Responses/SabnzbdRetryResponse.cs | 12 + .../Responses/SabnzbdVersionResponse.cs | 7 + .../Download/Clients/Sabnzbd/Sabnzbd.cs | 334 +++++++++++++ .../Clients/Sabnzbd/SabnzbdCategory.cs | 40 ++ .../Clients/Sabnzbd/SabnzbdDownloadStatus.cs | 22 + .../Clients/Sabnzbd/SabnzbdFullStatus.cs | 12 + .../Clients/Sabnzbd/SabnzbdHistory.cs | 13 + .../Clients/Sabnzbd/SabnzbdHistoryItem.cs | 29 ++ .../Clients/Sabnzbd/SabnzbdJsonError.cs | 13 + .../Clients/Sabnzbd/SabnzbdPriority.cs | 12 + .../Download/Clients/Sabnzbd/SabnzbdProxy.cs | 254 ++++++++++ .../Download/Clients/Sabnzbd/SabnzbdQueue.cs | 17 + .../Clients/Sabnzbd/SabnzbdQueueItem.cs | 35 ++ .../Clients/Sabnzbd/SabnzbdSettings.cs | 79 +++ .../Clients/TorrentSeedConfiguration.cs | 12 + .../Clients/Transmission/Transmission.cs | 43 ++ .../Clients/Transmission/TransmissionBase.cs | 183 +++++++ .../Transmission/TransmissionConfig.cs | 22 + .../Transmission/TransmissionException.cs | 10 + .../Transmission/TransmissionPriority.cs | 8 + .../Clients/Transmission/TransmissionProxy.cs | 320 ++++++++++++ .../Transmission/TransmissionResponse.cs | 10 + .../Transmission/TransmissionSettings.cs | 73 +++ .../Transmission/TransmissionTorrent.cs | 25 + .../Transmission/TransmissionTorrentStatus.cs | 13 + .../Download/Clients/Vuze/Vuze.cs | 62 +++ .../Download/Clients/rTorrent/RTorrent.cs | 164 +++++++ .../rTorrent/RTorrentDirectoryValidator.cs | 27 ++ .../Clients/rTorrent/RTorrentPriority.cs | 10 + .../Clients/rTorrent/RTorrentProxy.cs | 279 +++++++++++ .../Clients/rTorrent/RTorrentSettings.cs | 68 +++ .../Clients/rTorrent/RTorrentTorrent.cs | 17 + .../Download/Clients/uTorrent/UTorrent.cs | 188 +++++++ .../Clients/uTorrent/UTorrentPriority.cs | 8 + .../Clients/uTorrent/UTorrentProxy.cs | 295 +++++++++++ .../Clients/uTorrent/UTorrentResponse.cs | 25 + .../Clients/uTorrent/UTorrentSettings.cs | 63 +++ .../Clients/uTorrent/UTorrentTorrent.cs | 117 +++++ .../Clients/uTorrent/UTorrentTorrentCache.cs | 11 + .../Clients/uTorrent/UTorrentTorrentStatus.cs | 17 + .../Clients/uTorrent/UtorrentState.cs | 10 + .../Download/DownloadClientBase.cs | 100 ++++ .../Download/DownloadClientDefinition.cs | 11 + .../Download/DownloadClientFactory.cs | 86 ++++ .../Download/DownloadClientProvider.cs | 80 +++ .../Download/DownloadClientRepository.cs | 18 + .../Download/DownloadClientStatus.cs | 8 + .../DownloadClientStatusRepository.cs | 18 + .../Download/DownloadClientStatusService.cs | 22 + src/NzbDrone.Core/Download/DownloadService.cs | 164 +++++++ src/NzbDrone.Core/Download/GrabbedEvent.cs | 19 + src/NzbDrone.Core/Download/IDownloadClient.cs | 13 + .../Download/InvalidNzbException.cs | 28 ++ .../Download/NzbValidationService.cs | 53 ++ .../Download/TorrentClientBase.cs | 232 +++++++++ .../Download/TorrentFileInfoReader.cs | 17 + .../Download/UsenetClientBase.cs | 88 ++++ .../Checks/DownloadClientStatusCheck.cs | 46 ++ .../CleanupOrphanedDownloadClientStatus.cs | 27 ++ .../FixFutureApplicationStatusTimes.cs | 12 + .../FixFutureDownloadClientStatusTimes.cs | 12 + .../IndexerSearch/NzbSearchService.cs | 20 +- src/NzbDrone.Core/Indexers/DownloadService.cs | 92 ---- src/NzbDrone.Core/Indexers/HttpIndexerBase.cs | 4 +- src/NzbDrone.Core/Localization/Core/en.json | 16 + src/NzbDrone.Core/Parser/StringUtil.cs | 20 + .../DownloadClientController.cs | 26 + .../DownloadClient/DownloadClientResource.cs | 47 ++ .../Indexers/IndexerController.cs | 7 + .../Search/SearchController.cs | 62 ++- 209 files changed, 12229 insertions(+), 127 deletions(-) create mode 100644 frontend/src/Settings/DownloadClients/DownloadClientSettings.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js create mode 100644 frontend/src/Store/Actions/Settings/downloadClients.js create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugeLabel.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugePriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Deluge/DelugeUpdateUIResult.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadClientAuthenticationException.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadClientException.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadClientUnavailableException.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApi.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskAdditional.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskFile.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DSMInfoResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationAuthResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DownloadStationTaskInfoResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListFileInfoResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/FileStationListResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/SerialNumberProvider.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderMapping.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/SharedFolderResolver.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs create mode 100644 src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Flood/Flood.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Flood/Models/AdditionalTags.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentContent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentListSummary.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenSystemInfo.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentState.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexLoginResultTypeConverter.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexResultTypeConverter.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexAuthenticationException.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexFile.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexGroup.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexJsonError.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexLoginResultType.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexNotLoggedInException.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexQueueItem.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexResultType.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexStateType.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAddResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexApiVersionResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAuthNonceResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAuthResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexFilesResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexGroupResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexQueueResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexResponseBase.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexRetryResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexVersionResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/ErrorModel.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/JsonError.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetCategory.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetConfigItem.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetGlobalStatus.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetHistoryItem.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetParameter.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPostQueueItem.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetQueueItem.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Pneumatic/PneumaticSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdQueueTimeConverter.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdStringArrayConverter.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdAddResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdCategoryResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdConfigResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdFullStatusResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdRetryResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdVersionResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdDownloadStatus.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdFullStatus.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistory.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistoryItem.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdJsonError.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueueItem.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/TorrentSeedConfiguration.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionConfig.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionException.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs create mode 100644 src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs create mode 100644 src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentDirectoryValidator.cs create mode 100644 src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentCache.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentStatus.cs create mode 100644 src/NzbDrone.Core/Download/Clients/uTorrent/UtorrentState.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientBase.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientDefinition.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientFactory.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientProvider.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientRepository.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientStatus.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientStatusRepository.cs create mode 100644 src/NzbDrone.Core/Download/DownloadClientStatusService.cs create mode 100644 src/NzbDrone.Core/Download/DownloadService.cs create mode 100644 src/NzbDrone.Core/Download/GrabbedEvent.cs create mode 100644 src/NzbDrone.Core/Download/IDownloadClient.cs create mode 100644 src/NzbDrone.Core/Download/InvalidNzbException.cs create mode 100644 src/NzbDrone.Core/Download/NzbValidationService.cs create mode 100644 src/NzbDrone.Core/Download/TorrentClientBase.cs create mode 100644 src/NzbDrone.Core/Download/TorrentFileInfoReader.cs create mode 100644 src/NzbDrone.Core/Download/UsenetClientBase.cs create mode 100644 src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs create mode 100644 src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs create mode 100644 src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureApplicationStatusTimes.cs create mode 100644 src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimes.cs delete mode 100644 src/NzbDrone.Core/Indexers/DownloadService.cs create mode 100644 src/Prowlarr.Api.V1/DownloadClient/DownloadClientController.cs create mode 100644 src/Prowlarr.Api.V1/DownloadClient/DownloadClientResource.cs diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 694207be1..d9ff7b470 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -9,6 +9,7 @@ import StatsConnector from 'Indexer/Stats/StatsConnector'; import SearchIndexConnector from 'Search/SearchIndexConnector'; import ApplicationSettingsConnector from 'Settings/Applications/ApplicationSettingsConnector'; import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import NotificationSettings from 'Settings/Notifications/NotificationSettings'; import Settings from 'Settings/Settings'; @@ -94,6 +95,11 @@ function AppRoutes(props) { component={ApplicationSettingsConnector} /> + + { + const { + guid, + indexerId, + onGrabPress + } = this.props; + + onGrabPress({ + guid, + indexerId + }); + } + // // Render @@ -39,6 +91,9 @@ class SearchIndexRow extends Component { leechers, indexerFlags, columns, + isGrabbing, + isGrabbed, + grabError, longDateFormat, timeFormat } = this.props; @@ -214,11 +269,13 @@ class SearchIndexRow extends Component { key={column.name} className={styles[column.name]} > - ); @@ -118,7 +120,8 @@ SearchIndexTable.propTypes = { scroller: PropTypes.instanceOf(Element).isRequired, longDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired, - onSortPress: PropTypes.func.isRequired + onSortPress: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired }; export default SearchIndexTable; diff --git a/frontend/src/Search/Table/SearchIndexTableConnector.js b/frontend/src/Search/Table/SearchIndexTableConnector.js index d79ea3630..5cc2aad5e 100644 --- a/frontend/src/Search/Table/SearchIndexTableConnector.js +++ b/frontend/src/Search/Table/SearchIndexTableConnector.js @@ -1,20 +1,20 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { setReleasesSort } from 'Store/Actions/releaseActions'; +import { grabRelease, setReleasesSort } from 'Store/Actions/releaseActions'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import SearchIndexTable from './SearchIndexTable'; function createMapStateToProps() { return createSelector( (state) => state.app.dimensions, - (state) => state.releases.columns, + (state) => state.releases, createUISettingsSelector(), - (dimensions, columns, uiSettings) => { + (dimensions, releases, uiSettings) => { return { isSmallScreen: dimensions.isSmallScreen, - columns, longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat + timeFormat: uiSettings.timeFormat, + ...releases }; } ); @@ -24,6 +24,9 @@ function createMapDispatchToProps(dispatch, props) { return { onSortPress(sortKey) { dispatch(setReleasesSort({ sortKey })); + }, + onGrabPress(payload) { + dispatch(grabRelease(payload)); } }; } diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js new file mode 100644 index 000000000..8de472a6a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import { icons } from 'Helpers/Props'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import translate from 'Utilities/String/translate'; +import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; + +class DownloadClientSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isTestingAll, + dispatchTestAllDownloadClients + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + ); + } +} + +DownloadClientSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired +}; + +export default DownloadClientSettings; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js new file mode 100644 index 000000000..5e1a8a1ca --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllDownloadClients } from 'Store/Actions/settingsActions'; +import DownloadClientSettings from './DownloadClientSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllDownloadClients: testAllDownloadClients +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSettings); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css new file mode 100644 index 000000000..a3d90cc5a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css @@ -0,0 +1,44 @@ +.downloadClient { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js new file mode 100644 index 000000000..235356a5f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import { sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem'; +import styles from './AddDownloadClientItem.css'; + +class AddDownloadClientItem extends Component { + + // + // Listeners + + onDownloadClientSelect = () => { + const { + implementation + } = this.props; + + this.props.onDownloadClientSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onDownloadClientSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddDownloadClientItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onDownloadClientSelect: PropTypes.func.isRequired +}; + +export default AddDownloadClientItem; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js new file mode 100644 index 000000000..0c21e7dbd --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddDownloadClientModalContentConnector from './AddDownloadClientModalContentConnector'; + +function AddDownloadClientModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddDownloadClientModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddDownloadClientModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css new file mode 100644 index 000000000..b4d5c6787 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css @@ -0,0 +1,5 @@ +.downloadClients { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js new file mode 100644 index 000000000..b8dccffde --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Alert from 'Components/Alert'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import AddDownloadClientItem from './AddDownloadClientItem'; +import styles from './AddDownloadClientModalContent.css'; + +class AddDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients, + onDownloadClientSelect, + onModalClose + } = this.props; + + return ( + + + {translate('AddDownloadClient')} + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
+ {translate('UnableToAddANewDownloadClientPleaseTryAgain')} +
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
+ {translate('ProwlarrSupportsAnyDownloadClient')} +
+
+ {translate('ForMoreInformationOnTheIndividualDownloadClients')} +
+
+ +
+
+ { + usenetDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
+
+ +
+
+ { + torrentDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
+
+
+ } +
+ + + +
+ ); + } +} + +AddDownloadClientModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + onDownloadClientSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddDownloadClientModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js new file mode 100644 index 000000000..99d5c4f19 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClientSchema, selectDownloadClientSchema } from 'Store/Actions/settingsActions'; +import AddDownloadClientModalContent from './AddDownloadClientModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (downloadClients) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = downloadClients; + + const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' }); + const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' }); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients + }; + } + ); +} + +const mapDispatchToProps = { + fetchDownloadClientSchema, + selectDownloadClientSchema +}; + +class AddDownloadClientModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDownloadClientSchema(); + } + + // + // Listeners + + onDownloadClientSelect = ({ implementation }) => { + this.props.selectDownloadClientSchema({ implementation }); + this.props.onModalClose({ downloadClientSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddDownloadClientModalContentConnector.propTypes = { + fetchDownloadClientSchema: PropTypes.func.isRequired, + selectDownloadClientSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js new file mode 100644 index 000000000..f356f8140 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddDownloadClientPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddDownloadClientPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddDownloadClientPresetMenuItem; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css new file mode 100644 index 000000000..8eea80383 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css @@ -0,0 +1,19 @@ +.downloadClient { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js new file mode 100644 index 000000000..98fce6319 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -0,0 +1,126 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; +import styles from './DownloadClient.css'; + +class DownloadClient extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditDownloadClientModalOpen: false, + isDeleteDownloadClientModalOpen: false + }; + } + + // + // Listeners + + onEditDownloadClientPress = () => { + this.setState({ isEditDownloadClientModalOpen: true }); + } + + onEditDownloadClientModalClose = () => { + this.setState({ isEditDownloadClientModalOpen: false }); + } + + onDeleteDownloadClientPress = () => { + this.setState({ + isEditDownloadClientModalOpen: false, + isDeleteDownloadClientModalOpen: true + }); + } + + onDeleteDownloadClientModalClose= () => { + this.setState({ isDeleteDownloadClientModalOpen: false }); + } + + onConfirmDeleteDownloadClient = () => { + this.props.onConfirmDeleteDownloadClient(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enable, + priority + } = this.props; + + return ( + +
+ {name} +
+ +
+ { + enable ? + : + + } + + { + priority > 1 && + + } +
+ + + + +
+ ); + } +} + +DownloadClient.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enable: PropTypes.bool.isRequired, + priority: PropTypes.number.isRequired, + onConfirmDeleteDownloadClient: PropTypes.func.isRequired +}; + +export default DownloadClient; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css new file mode 100644 index 000000000..81b4f1510 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css @@ -0,0 +1,20 @@ +.downloadClients { + display: flex; + flex-wrap: wrap; +} + +.addDownloadClient { + composes: downloadClient from '~./DownloadClient.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js new file mode 100644 index 000000000..52f211f4a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import AddDownloadClientModal from './AddDownloadClientModal'; +import DownloadClient from './DownloadClient'; +import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; +import styles from './DownloadClients.css'; + +class DownloadClients extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddDownloadClientModalOpen: false, + isEditDownloadClientModalOpen: false + }; + } + + // + // Listeners + + onAddDownloadClientPress = () => { + this.setState({ isAddDownloadClientModalOpen: true }); + } + + onAddDownloadClientModalClose = ({ downloadClientSelected = false } = {}) => { + this.setState({ + isAddDownloadClientModalOpen: false, + isEditDownloadClientModalOpen: downloadClientSelected + }); + } + + onEditDownloadClientModalClose = () => { + this.setState({ isEditDownloadClientModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteDownloadClient, + ...otherProps + } = this.props; + + const { + isAddDownloadClientModalOpen, + isEditDownloadClientModalOpen + } = this.state; + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +DownloadClients.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteDownloadClient: PropTypes.func.isRequired +}; + +export default DownloadClients; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js new file mode 100644 index 000000000..ed8ddffc9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import sortByName from 'Utilities/Array/sortByName'; +import DownloadClients from './DownloadClients'; + +function createMapStateToProps() { + return createSelector( + createSortedSectionSelector('settings.downloadClients', sortByName), + (downloadClients) => downloadClients + ); +} + +const mapDispatchToProps = { + fetchDownloadClients, + deleteDownloadClient +}; + +class DownloadClientsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDownloadClients(); + } + + // + // Listeners + + onConfirmDeleteDownloadClient = (id) => { + this.props.deleteDownloadClient({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientsConnector.propTypes = { + fetchDownloadClients: PropTypes.func.isRequired, + deleteDownloadClient: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js new file mode 100644 index 000000000..4fd6b607d --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import EditDownloadClientModalContentConnector from './EditDownloadClientModalContentConnector'; + +function EditDownloadClientModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditDownloadClientModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditDownloadClientModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js new file mode 100644 index 000000000..e6b06974d --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelSaveDownloadClient, cancelTestDownloadClient } from 'Store/Actions/settingsActions'; +import EditDownloadClientModal from './EditDownloadClientModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.downloadClients'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestDownloadClient() { + dispatch(cancelTestDownloadClient({ section })); + }, + + dispatchCancelSaveDownloadClient() { + dispatch(cancelSaveDownloadClient({ section })); + } + }; +} + +class EditDownloadClientModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestDownloadClient(); + this.props.dispatchCancelSaveDownloadClient(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestDownloadClient, + dispatchCancelSaveDownloadClient, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditDownloadClientModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestDownloadClient: PropTypes.func.isRequired, + dispatchCancelSaveDownloadClient: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditDownloadClientModalConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css new file mode 100644 index 000000000..8e1c16507 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js new file mode 100644 index 000000000..8aba1b678 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -0,0 +1,197 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Alert from 'Components/Alert'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './EditDownloadClientModalContent.css'; + +class EditDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteDownloadClientPress, + ...otherProps + } = this.props; + + const { + id, + implementationName, + name, + enable, + priority, + fields, + message + } = item; + + return ( + + + {`${id ? translate('Edit') : translate('Add')} ${translate('DownloadClient')} - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
+ {translate('UnableToAddANewDownloadClientPleaseTryAgain')} +
+ } + + { + !isFetching && !error && +
+ { + !!message && + + {message.value.message} + + } + + + {translate('Name')} + + + + + + {translate('Enable')} + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + {translate('ClientPriority')} + + + + + + } +
+ + { + id && + + } + + + {translate('Test')} + + + + + + {translate('Save')} + + +
+ ); + } +} + +EditDownloadClientModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isTesting: PropTypes.bool.isRequired, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteDownloadClientPress: PropTypes.func +}; + +export default EditDownloadClientModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js new file mode 100644 index 000000000..864d83cfe --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { saveDownloadClient, setDownloadClientFieldValue, setDownloadClientValue, testDownloadClient } from 'Store/Actions/settingsActions'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import EditDownloadClientModalContent from './EditDownloadClientModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('downloadClients'), + (advancedSettings, downloadClient) => { + return { + advancedSettings, + ...downloadClient + }; + } + ); +} + +const mapDispatchToProps = { + setDownloadClientValue, + setDownloadClientFieldValue, + saveDownloadClient, + testDownloadClient +}; + +class EditDownloadClientModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setDownloadClientValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setDownloadClientFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveDownloadClient({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testDownloadClient({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDownloadClientModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setDownloadClientValue: PropTypes.func.isRequired, + setDownloadClientFieldValue: PropTypes.func.isRequired, + saveDownloadClient: PropTypes.func.isRequired, + testDownloadClient: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditDownloadClientModalContentConnector); diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js index a967d86c3..c29cb7cef 100644 --- a/frontend/src/Settings/Settings.js +++ b/frontend/src/Settings/Settings.js @@ -25,6 +25,17 @@ function Settings() { Applications and settings to configure how prowlarr interacts with your PVR programs + + {translate('DownloadClients')} + + +
+ {translate('DownloadClientsSettingsSummary')} +
+ { + return { + section, + ...payload + }; +}); + +export const setDownloadClientFieldValue = createAction(SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'), + [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'), + + [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'), + [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section), + [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), + [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), + [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer(section), + [SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enable = true; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js index c95907a59..9b11515e6 100644 --- a/frontend/src/Store/Actions/releaseActions.js +++ b/frontend/src/Store/Actions/releaseActions.js @@ -247,7 +247,7 @@ export const actionHandlers = handleThunks({ dispatch(updateRelease({ guid, isGrabbing: true })); const promise = createAjaxRequest({ - url: '/release', + url: '/search', method: 'POST', contentType: 'application/json', data: JSON.stringify(payload) diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 4e9e8dc14..662456e2d 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -3,6 +3,7 @@ import { handleThunks } from 'Store/thunks'; import createHandleActions from './Creators/createHandleActions'; import applications from './Settings/applications'; import development from './Settings/development'; +import downloadClients from './Settings/downloadClients'; import general from './Settings/general'; import indexerCategories from './Settings/indexerCategories'; import indexerFlags from './Settings/indexerFlags'; @@ -10,6 +11,7 @@ import languages from './Settings/languages'; import notifications from './Settings/notifications'; import ui from './Settings/ui'; +export * from './Settings/downloadClients'; export * from './Settings/general'; export * from './Settings/indexerCategories'; export * from './Settings/indexerFlags'; @@ -30,6 +32,7 @@ export const section = 'settings'; export const defaultState = { advancedSettings: false, + downloadClients: downloadClients.defaultState, general: general.defaultState, indexerCategories: indexerCategories.defaultState, indexerFlags: indexerFlags.defaultState, @@ -58,6 +61,7 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); // Action Handlers export const actionHandlers = handleThunks({ + ...downloadClients.actionHandlers, ...general.actionHandlers, ...indexerCategories.actionHandlers, ...indexerFlags.actionHandlers, @@ -77,6 +81,7 @@ export const reducers = createHandleActions({ return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); }, + ...downloadClients.reducers, ...general.reducers, ...indexerCategories.reducers, ...indexerFlags.reducers, diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 0a9eed911..4284095e2 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -8,6 +8,7 @@ using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.CustomFilters; using NzbDrone.Core.Datastore.Converters; +using NzbDrone.Core.Download; using NzbDrone.Core.Indexers; using NzbDrone.Core.Instrumentation; using NzbDrone.Core.Jobs; @@ -49,6 +50,11 @@ namespace NzbDrone.Core.Datastore .Ignore(i => i.Capabilities) .Ignore(d => d.Tags); + Mapper.Entity("DownloadClients").RegisterModel() + .Ignore(x => x.ImplementationName) + .Ignore(i => i.Protocol) + .Ignore(d => d.Tags); + Mapper.Entity("Notifications").RegisterModel() .Ignore(x => x.ImplementationName) .Ignore(i => i.SupportsOnHealthIssue); @@ -70,6 +76,8 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("IndexerStatus").RegisterModel(); + Mapper.Entity("DownloadClientStatus").RegisterModel(); + Mapper.Entity("ApplicationStatus").RegisterModel(); Mapper.Entity("CustomFilters").RegisterModel(); diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs new file mode 100644 index 000000000..bad9e1a91 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class Deluge : TorrentClientBase + { + private readonly IDelugeProxy _proxy; + + public Deluge(IDelugeProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + _proxy = proxy; + } + + protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + { + var actualHash = _proxy.AddTorrentFromMagnet(magnetLink, Settings); + + if (actualHash.IsNullOrWhiteSpace()) + { + throw new DownloadClientException("Deluge failed to add magnet " + magnetLink); + } + + // _proxy.SetTorrentSeedingConfiguration(actualHash, remoteMovie.SeedConfiguration, Settings); + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + _proxy.SetTorrentLabel(actualHash, Settings.Category, Settings); + } + + if (Settings.Priority == (int)DelugePriority.First) + { + _proxy.MoveTorrentToTopInQueue(actualHash, Settings); + } + + return actualHash.ToUpper(); + } + + protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + { + var actualHash = _proxy.AddTorrentFromFile(filename, fileContent, Settings); + + if (actualHash.IsNullOrWhiteSpace()) + { + throw new DownloadClientException("Deluge failed to add torrent " + filename); + } + + // _proxy.SetTorrentSeedingConfiguration(actualHash, release.SeedConfiguration, Settings); + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + _proxy.SetTorrentLabel(actualHash, Settings.Category, Settings); + } + + if (Settings.Priority == (int)DelugePriority.First) + { + _proxy.MoveTorrentToTopInQueue(actualHash, Settings); + } + + return actualHash.ToUpper(); + } + + public override string Name => "Deluge"; + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.HasErrors()) + { + return; + } + + failures.AddIfNotNull(TestCategory()); + failures.AddIfNotNull(TestGetTorrents()); + } + + private ValidationFailure TestConnection() + { + try + { + _proxy.GetVersion(Settings); + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + + return new NzbDroneValidationFailure("Password", "Authentication failed"); + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to test connection"); + switch (ex.Status) + { + case WebExceptionStatus.ConnectFailure: + return new NzbDroneValidationFailure("Host", "Unable to connect") + { + DetailedDescription = "Please verify the hostname and port." + }; + case WebExceptionStatus.ConnectionClosed: + return new NzbDroneValidationFailure("UseSsl", "Verify SSL settings") + { + DetailedDescription = "Please verify your SSL configuration on both Deluge and NzbDrone." + }; + case WebExceptionStatus.SecureChannelFailure: + return new NzbDroneValidationFailure("UseSsl", "Unable to connect through SSL") + { + DetailedDescription = "Drone is unable to connect to Deluge using SSL. This problem could be computer related. Please try to configure both drone and Deluge to not use SSL." + }; + default: + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to test connection"); + + return new NzbDroneValidationFailure("Host", "Unable to connect to Deluge") + { + DetailedDescription = ex.Message + }; + } + + return null; + } + + private ValidationFailure TestCategory() + { + if (Settings.Category.IsNullOrWhiteSpace() && Settings.Category.IsNullOrWhiteSpace()) + { + return null; + } + + var enabledPlugins = _proxy.GetEnabledPlugins(Settings); + + if (!enabledPlugins.Contains("Label")) + { + return new NzbDroneValidationFailure("Category", "Label plugin not activated") + { + DetailedDescription = "You must have the Label plugin enabled in Deluge to use categories." + }; + } + + var labels = _proxy.GetAvailableLabels(Settings); + + if (Settings.Category.IsNotNullOrWhiteSpace() && !labels.Contains(Settings.Category)) + { + _proxy.AddLabel(Settings.Category, Settings); + labels = _proxy.GetAvailableLabels(Settings); + + if (!labels.Contains(Settings.Category)) + { + return new NzbDroneValidationFailure("Category", "Configuration of label failed") + { + DetailedDescription = "Prowlarr was unable to add the label to Deluge." + }; + } + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to get torrents"); + return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + + protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs new file mode 100644 index 000000000..b3a00d85e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeError + { + public string Message { get; set; } + public int Code { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs new file mode 100644 index 000000000..45df09c8c --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs @@ -0,0 +1,13 @@ +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeException : DownloadClientException + { + public int Code { get; set; } + + public DelugeException(string message, int code) + : base(message + " (code " + code + ")") + { + Code = code; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeLabel.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeLabel.cs new file mode 100644 index 000000000..a5da831ab --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeLabel.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeLabel + { + [JsonProperty(PropertyName = "apply_move_completed")] + public bool ApplyMoveCompleted { get; set; } + + [JsonProperty(PropertyName = "move_completed")] + public bool MoveCompleted { get; set; } + + [JsonProperty(PropertyName = "move_completed_path")] + public string MoveCompletedPath { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugePriority.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugePriority.cs new file mode 100644 index 000000000..741392e88 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugePriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public enum DelugePriority + { + Last = 0, + First = 1 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs new file mode 100644 index 000000000..5248e6f20 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public interface IDelugeProxy + { + string GetVersion(DelugeSettings settings); + Dictionary GetConfig(DelugeSettings settings); + DelugeTorrent[] GetTorrents(DelugeSettings settings); + DelugeTorrent[] GetTorrentsByLabel(string label, DelugeSettings settings); + string[] GetAvailablePlugins(DelugeSettings settings); + string[] GetEnabledPlugins(DelugeSettings settings); + string[] GetAvailableLabels(DelugeSettings settings); + DelugeLabel GetLabelOptions(DelugeSettings settings); + void SetTorrentLabel(string hash, string label, DelugeSettings settings); + void SetTorrentConfiguration(string hash, string key, object value, DelugeSettings settings); + void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, DelugeSettings settings); + void AddLabel(string label, DelugeSettings settings); + string AddTorrentFromMagnet(string magnetLink, DelugeSettings settings); + string AddTorrentFromFile(string filename, byte[] fileContent, DelugeSettings settings); + bool RemoveTorrent(string hash, bool removeData, DelugeSettings settings); + void MoveTorrentToTopInQueue(string hash, DelugeSettings settings); + } + + public class DelugeProxy : IDelugeProxy + { + private static readonly string[] RequiredProperties = new string[] { "hash", "name", "state", "progress", "eta", "message", "is_finished", "save_path", "total_size", "total_done", "time_added", "active_time", "ratio", "is_auto_managed", "stop_at_ratio", "remove_at_ratio", "stop_ratio" }; + + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + private readonly ICached> _authCookieCache; + + public DelugeProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public string GetVersion(DelugeSettings settings) + { + try + { + var response = ProcessRequest(settings, "daemon.info"); + + return response; + } + catch (DownloadClientException ex) + { + if (ex.Message.Contains("Unknown method")) + { + // Deluge v2 beta replaced 'daemon.info' with 'daemon.get_version'. + // It may return or become official, for now we just retry with the get_version api. + var response = ProcessRequest(settings, "daemon.get_version"); + + return response; + } + + throw; + } + } + + public Dictionary GetConfig(DelugeSettings settings) + { + var response = ProcessRequest>(settings, "core.get_config"); + + return response; + } + + public DelugeTorrent[] GetTorrents(DelugeSettings settings) + { + var filter = new Dictionary(); + + // TODO: get_torrents_status returns the files as well, which starts to cause deluge timeouts when you get enough season packs. + //var response = ProcessRequest>(settings, "core.get_torrents_status", filter, new String[0]); + var response = ProcessRequest(settings, "web.update_ui", RequiredProperties, filter); + + return GetTorrents(response); + } + + public DelugeTorrent[] GetTorrentsByLabel(string label, DelugeSettings settings) + { + var filter = new Dictionary(); + filter.Add("label", label); + + //var response = ProcessRequest>(settings, "core.get_torrents_status", filter, new String[0]); + var response = ProcessRequest(settings, "web.update_ui", RequiredProperties, filter); + + return GetTorrents(response); + } + + public string AddTorrentFromMagnet(string magnetLink, DelugeSettings settings) + { + var options = new + { + add_paused = settings.AddPaused, + remove_at_ratio = false + }; + + var response = ProcessRequest(settings, "core.add_torrent_magnet", magnetLink, options); + + return response; + } + + public string AddTorrentFromFile(string filename, byte[] fileContent, DelugeSettings settings) + { + var options = new + { + add_paused = settings.AddPaused, + remove_at_ratio = false + }; + + var response = ProcessRequest(settings, "core.add_torrent_file", filename, fileContent, options); + + return response; + } + + public bool RemoveTorrent(string hash, bool removeData, DelugeSettings settings) + { + var response = ProcessRequest(settings, "core.remove_torrent", hash, removeData); + + return response; + } + + public void MoveTorrentToTopInQueue(string hash, DelugeSettings settings) + { + ProcessRequest(settings, "core.queue_top", (object)new string[] { hash }); + } + + public string[] GetAvailablePlugins(DelugeSettings settings) + { + var response = ProcessRequest(settings, "core.get_available_plugins"); + + return response; + } + + public string[] GetEnabledPlugins(DelugeSettings settings) + { + var response = ProcessRequest(settings, "core.get_enabled_plugins"); + + return response; + } + + public string[] GetAvailableLabels(DelugeSettings settings) + { + var response = ProcessRequest(settings, "label.get_labels"); + + return response; + } + + public DelugeLabel GetLabelOptions(DelugeSettings settings) + { + var response = ProcessRequest(settings, "label.get_options", settings.Category); + + return response; + } + + public void SetTorrentConfiguration(string hash, string key, object value, DelugeSettings settings) + { + var arguments = new Dictionary(); + arguments.Add(key, value); + + ProcessRequest(settings, "core.set_torrent_options", new string[] { hash }, arguments); + } + + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, DelugeSettings settings) + { + if (seedConfiguration == null) + { + return; + } + + var ratioArguments = new Dictionary(); + + if (seedConfiguration.Ratio != null) + { + ratioArguments.Add("stop_ratio", seedConfiguration.Ratio.Value); + ratioArguments.Add("stop_at_ratio", 1); + } + + ProcessRequest(settings, "core.set_torrent_options", new[] { hash }, ratioArguments); + } + + public void AddLabel(string label, DelugeSettings settings) + { + ProcessRequest(settings, "label.add", label); + } + + public void SetTorrentLabel(string hash, string label, DelugeSettings settings) + { + ProcessRequest(settings, "label.set_torrent", hash, label); + } + + private JsonRpcRequestBuilder BuildRequest(DelugeSettings settings) + { + string url = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + + var requestBuilder = new JsonRpcRequestBuilder(url); + requestBuilder.LogResponseContent = true; + + requestBuilder.Resource("json"); + requestBuilder.PostProcess += r => r.RequestTimeout = TimeSpan.FromSeconds(15); + + AuthenticateClient(requestBuilder, settings); + + return requestBuilder; + } + + protected TResult ProcessRequest(DelugeSettings settings, string method, params object[] arguments) + { + var requestBuilder = BuildRequest(settings); + + var response = ExecuteRequest(requestBuilder, method, arguments); + + if (response.Error != null) + { + var error = response.Error.ToObject(); + if (error.Code == 1 || error.Code == 2) + { + AuthenticateClient(requestBuilder, settings, true); + + response = ExecuteRequest(requestBuilder, method, arguments); + + if (response.Error == null) + { + return response.Result; + } + + error = response.Error.ToObject(); + + throw new DownloadClientAuthenticationException(error.Message); + } + + throw new DelugeException(error.Message, error.Code); + } + + return response.Result; + } + + private JsonRpcResponse ExecuteRequest(JsonRpcRequestBuilder requestBuilder, string method, params object[] arguments) + { + var request = requestBuilder.Call(method, arguments).Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(request); + + return Json.Deserialize>(response.Content); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.RequestTimeout) + { + _logger.Debug("Deluge timeout during request, daemon connection may have been broken. Attempting to reconnect."); + return new JsonRpcResponse() + { + Error = JToken.Parse("{ Code = 2 }") + }; + } + else + { + throw new DownloadClientException("Unable to connect to Deluge, please check your settings", ex); + } + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to Deluge, certificate validation failed.", ex); + } + + throw new DownloadClientUnavailableException("Unable to connect to Deluge, please check your settings", ex); + } + } + + private void VerifyResponse(JsonRpcResponse response) + { + if (response.Error != null) + { + var error = response.Error.ToObject(); + throw new DelugeException(error.Message, error.Code); + } + } + + private void AuthenticateClient(JsonRpcRequestBuilder requestBuilder, DelugeSettings settings, bool reauthenticate = false) + { + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var cookies = _authCookieCache.Find(authKey); + + if (cookies == null || reauthenticate) + { + _authCookieCache.Remove(authKey); + + var authLoginRequest = requestBuilder.Call("auth.login", settings.Password).Build(); + var response = _httpClient.Execute(authLoginRequest); + var result = Json.Deserialize>(response.Content); + if (!result.Result) + { + _logger.Debug("Deluge authentication failed."); + throw new DownloadClientAuthenticationException("Failed to authenticate with Deluge."); + } + + _logger.Debug("Deluge authentication succeeded."); + + cookies = response.GetCookies(); + + _authCookieCache.Set(authKey, cookies); + + requestBuilder.SetCookies(cookies); + + ConnectDaemon(requestBuilder); + } + else + { + requestBuilder.SetCookies(cookies); + } + } + + private void ConnectDaemon(JsonRpcRequestBuilder requestBuilder) + { + var resultConnected = ExecuteRequest(requestBuilder, "web.connected"); + VerifyResponse(resultConnected); + + if (resultConnected.Result) + { + return; + } + + var resultHosts = ExecuteRequest>(requestBuilder, "web.get_hosts"); + VerifyResponse(resultHosts); + + if (resultHosts.Result != null) + { + // The returned list contains the id, ip, port and status of each available connection. We want the 127.0.0.1 + var connection = resultHosts.Result.FirstOrDefault(v => (v[1] as string) == "127.0.0.1"); + + if (connection != null) + { + var resultConnect = ExecuteRequest(requestBuilder, "web.connect", new object[] { connection[0] }); + VerifyResponse(resultConnect); + + return; + } + } + + throw new DownloadClientException("Failed to connect to Deluge daemon."); + } + + private DelugeTorrent[] GetTorrents(DelugeUpdateUIResult result) + { + if (result.Torrents == null) + { + return Array.Empty(); + } + + return result.Torrents.Values.ToArray(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs new file mode 100644 index 000000000..d9b7edc3e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs @@ -0,0 +1,60 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeSettingsValidator : AbstractValidator + { + public DelugeSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + + RuleFor(c => c.Category).Matches("^[-a-z0-9]*$").WithMessage("Allowed characters a-z, 0-9 and -"); + } + } + + public class DelugeSettings : IProviderConfig + { + private static readonly DelugeSettingsValidator Validator = new DelugeSettingsValidator(); + + public DelugeSettings() + { + Host = "localhost"; + Port = 8112; + Password = "deluge"; + Category = "prowlarr"; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Deluge")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the deluge json url, see http://[host]:[port]/[urlBase]/json")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + public string Category { get; set; } + + [FieldDefinition(6, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing items")] + public int Priority { get; set; } + + [FieldDefinition(7, Label = "Add Paused", Type = FieldType.Checkbox)] + public bool AddPaused { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs new file mode 100644 index 000000000..4c7315cc9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs @@ -0,0 +1,55 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeTorrent + { + public string Hash { get; set; } + public string Name { get; set; } + public string State { get; set; } + public double Progress { get; set; } + public double Eta { get; set; } + public string Message { get; set; } + + [JsonProperty(PropertyName = "is_finished")] + public bool IsFinished { get; set; } + + // Other paths: What is the difference between 'move_completed_path' and 'move_on_completed_path'? + /* + [JsonProperty(PropertyName = "move_completed_path")] + public String DownloadPathMoveCompleted { get; set; } + [JsonProperty(PropertyName = "move_on_completed_path")] + public String DownloadPathMoveOnCompleted { get; set; } + */ + + [JsonProperty(PropertyName = "save_path")] + public string DownloadPath { get; set; } + + [JsonProperty(PropertyName = "total_size")] + public long Size { get; set; } + + [JsonProperty(PropertyName = "total_done")] + public long BytesDownloaded { get; set; } + + [JsonProperty(PropertyName = "time_added")] + public double DateAdded { get; set; } + + [JsonProperty(PropertyName = "active_time")] + public int SecondsDownloading { get; set; } + + [JsonProperty(PropertyName = "ratio")] + public double Ratio { get; set; } + + [JsonProperty(PropertyName = "is_auto_managed")] + public bool IsAutoManaged { get; set; } + + [JsonProperty(PropertyName = "stop_at_ratio")] + public bool StopAtRatio { get; set; } + + [JsonProperty(PropertyName = "remove_at_ratio")] + public bool RemoveAtRatio { get; set; } + + [JsonProperty(PropertyName = "stop_ratio")] + public double StopRatio { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs new file mode 100644 index 000000000..e9f5a6a70 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs @@ -0,0 +1,12 @@ +namespace NzbDrone.Core.Download.Clients.Deluge +{ + internal class DelugeTorrentStatus + { + public const string Paused = "Paused"; + public const string Queued = "Queued"; + public const string Downloading = "Downloading"; + public const string Seeding = "Seeding"; + public const string Checking = "Checking"; + public const string Error = "Error"; + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeUpdateUIResult.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeUpdateUIResult.cs new file mode 100644 index 000000000..d2d9294a8 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeUpdateUIResult.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Deluge +{ + public class DelugeUpdateUIResult + { + public Dictionary Stats { get; set; } + public bool Connected { get; set; } + public Dictionary Torrents { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadClientAuthenticationException.cs b/src/NzbDrone.Core/Download/Clients/DownloadClientAuthenticationException.cs new file mode 100644 index 000000000..6d27bb9bd --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadClientAuthenticationException.cs @@ -0,0 +1,27 @@ +using System; + +namespace NzbDrone.Core.Download.Clients +{ + public class DownloadClientAuthenticationException : DownloadClientException + { + public DownloadClientAuthenticationException(string message, params object[] args) + : base(message, args) + { + } + + public DownloadClientAuthenticationException(string message) + : base(message) + { + } + + public DownloadClientAuthenticationException(string message, Exception innerException, params object[] args) + : base(message, innerException, args) + { + } + + public DownloadClientAuthenticationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs b/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs new file mode 100644 index 000000000..0e62ec97e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs @@ -0,0 +1,28 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Download.Clients +{ + public class DownloadClientException : NzbDroneException + { + public DownloadClientException(string message, params object[] args) + : base(string.Format(message, args)) + { + } + + public DownloadClientException(string message) + : base(message) + { + } + + public DownloadClientException(string message, Exception innerException, params object[] args) + : base(string.Format(message, args), innerException) + { + } + + public DownloadClientException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadClientUnavailableException.cs b/src/NzbDrone.Core/Download/Clients/DownloadClientUnavailableException.cs new file mode 100644 index 000000000..1878f2adb --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadClientUnavailableException.cs @@ -0,0 +1,27 @@ +using System; + +namespace NzbDrone.Core.Download.Clients +{ + public class DownloadClientUnavailableException : DownloadClientException + { + public DownloadClientUnavailableException(string message, params object[] args) + : base(string.Format(message, args)) + { + } + + public DownloadClientUnavailableException(string message) + : base(message) + { + } + + public DownloadClientUnavailableException(string message, Exception innerException, params object[] args) + : base(string.Format(message, args), innerException) + { + } + + public DownloadClientUnavailableException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} 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..60f84c672 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs @@ -0,0 +1,33 @@ +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DiskStationApiInfo + { + private string _path; + + public int MaxVersion { get; set; } + + public int MinVersion { get; set; } + + public DiskStationApi Type { get; set; } + + public string Name { get; set; } + + public bool NeedsAuthentication { 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/DownloadStationSettings.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs new file mode 100644 index 000000000..497fd2fa8 --- /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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Download Station")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(4, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] + public string TvCategory { get; set; } + + [FieldDefinition(6, 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; } + + public DownloadStationSettings() + { + Host = "127.0.0.1"; + Port = 5000; + } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs new file mode 100644 index 000000000..41faac633 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStationTask + { + public string Username { get; set; } + + public string Id { get; set; } + + public string Title { get; set; } + + public long Size { get; set; } + + /// + /// /// Possible values are: BT, NZB, http, ftp, eMule and https + /// + public string Type { get; set; } + + [JsonProperty(PropertyName = "status_extra")] + public Dictionary StatusExtra { get; set; } + + [JsonConverter(typeof(UnderscoreStringEnumConverter), DownloadStationTaskStatus.Unknown)] + public DownloadStationTaskStatus Status { get; set; } + + public DownloadStationTaskAdditional Additional { get; set; } + + public override string ToString() + { + return Title; + } + } + + public enum DownloadStationTaskType + { + BT, + NZB, + http, + ftp, + eMule, + https + } + + public enum DownloadStationTaskStatus + { + Unknown, + Waiting, + Downloading, + Paused, + Finishing, + Finished, + HashChecking, + Seeding, + FilehostingWaiting, + Extracting, + Error, + CaptchaNeeded + } + + public enum DownloadStationPriority + { + Auto, + Low, + Normal, + High + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskAdditional.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskAdditional.cs new file mode 100644 index 000000000..81e55569e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskAdditional.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStationTaskAdditional + { + 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/DownloadStationTaskFile.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskFile.cs new file mode 100644 index 000000000..2538e3e10 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTaskFile.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class DownloadStationTaskFile + { + 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..322296c06 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs @@ -0,0 +1,31 @@ +using NLog; +using NzbDrone.Common.Cache; +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, ICacheManager cacheManager, Logger logger) + : base(DiskStationApi.DSMInfo, "SYNO.DSM.Info", httpClient, cacheManager, logger) + { + } + + public string GetSerialNumber(DownloadStationSettings settings) + { + var info = GetApiInfo(settings); + + var requestBuilder = BuildRequest(settings, "getinfo", info.MinVersion); + + var response = ProcessRequest(requestBuilder, "get serial number", settings); + + return response.Data.SerialNumber; + } + } +} 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..fc0837f55 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.DownloadStation.Responses; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IDiskStationProxy + { + DiskStationApiInfo GetApiInfo(DownloadStationSettings settings); + } + + public abstract class DiskStationProxyBase : IDiskStationProxy + { + protected readonly Logger _logger; + + private readonly IHttpClient _httpClient; + private readonly ICached _infoCache; + private readonly ICached _sessionCache; + private readonly DiskStationApi _apiType; + private readonly string _apiName; + + private static readonly DiskStationApiInfo _apiInfo; + + static DiskStationProxyBase() + { + _apiInfo = new DiskStationApiInfo() + { + Type = DiskStationApi.Info, + Name = "SYNO.API.Info", + Path = "query.cgi", + MaxVersion = 1, + MinVersion = 1, + NeedsAuthentication = false + }; + } + + public DiskStationProxyBase(DiskStationApi apiType, + string apiName, + IHttpClient httpClient, + ICacheManager cacheManager, + Logger logger) + { + _httpClient = httpClient; + _logger = logger; + _infoCache = cacheManager.GetCache(typeof(DiskStationProxyBase), "apiInfo"); + _sessionCache = cacheManager.GetCache(typeof(DiskStationProxyBase), "sessions"); + _apiType = apiType; + _apiName = apiName; + } + + private string GenerateSessionCacheKey(DownloadStationSettings settings) + { + return $"{settings.Username}@{settings.Host}:{settings.Port}"; + } + + protected DiskStationResponse ProcessRequest(HttpRequestBuilder requestBuilder, + string operation, + DownloadStationSettings settings) + where T : new() + { + return ProcessRequest(requestBuilder, operation, _apiType, settings); + } + + private DiskStationResponse ProcessRequest(HttpRequestBuilder requestBuilder, + string operation, + DiskStationApi api, + DownloadStationSettings settings) + where T : new() + { + var request = requestBuilder.Build(); + HttpResponse response; + + try + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + throw new DownloadClientException("Unable to connect to Diskstation, please check your settings", ex); + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to Diskstation, certificate validation failed.", ex); + } + + throw new DownloadClientUnavailableException("Unable to connect to Diskstation, please check your settings", ex); + } + + _logger.Debug("Trying to {0}", operation); + + if (response.StatusCode == HttpStatusCode.OK) + { + var responseContent = Json.Deserialize>(response.Content); + + if (responseContent.Success) + { + return responseContent; + } + else + { + var msg = $"Failed to {operation}. Reason: {responseContent.Error.GetMessage(api)}"; + _logger.Error(msg); + + if (responseContent.Error.SessionError) + { + _sessionCache.Remove(GenerateSessionCacheKey(settings)); + + if (responseContent.Error.Code == 105) + { + throw new DownloadClientAuthenticationException(msg); + } + } + + throw new DownloadClientException(msg); + } + } + else + { + throw new HttpException(request, response); + } + } + + private string AuthenticateClient(DownloadStationSettings settings) + { + var authInfo = GetApiInfo(DiskStationApi.Auth, settings); + + var requestBuilder = BuildRequest(settings, authInfo, "login", authInfo.MaxVersion >= 7 ? 6 : 2); + requestBuilder.AddQueryParam("account", settings.Username); + requestBuilder.AddQueryParam("passwd", settings.Password); + requestBuilder.AddQueryParam("format", "sid"); + requestBuilder.AddQueryParam("session", "DownloadStation"); + + var authResponse = ProcessRequest(requestBuilder, "login", DiskStationApi.Auth, settings); + + return authResponse.Data.SId; + } + + protected HttpRequestBuilder BuildRequest(DownloadStationSettings settings, string methodName, int apiVersion, HttpMethod httpVerb = HttpMethod.GET) + { + var info = GetApiInfo(_apiType, settings); + + return BuildRequest(settings, info, methodName, apiVersion, httpVerb); + } + + private HttpRequestBuilder BuildRequest(DownloadStationSettings settings, DiskStationApiInfo apiInfo, string methodName, int apiVersion, HttpMethod httpVerb = HttpMethod.GET) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port).Resource($"webapi/{apiInfo.Path}"); + requestBuilder.Method = httpVerb; + requestBuilder.LogResponseContent = true; + requestBuilder.SuppressHttpError = true; + requestBuilder.AllowAutoRedirect = false; + requestBuilder.Headers.ContentType = "application/json"; + + if (apiVersion < apiInfo.MinVersion || apiVersion > apiInfo.MaxVersion) + { + throw new ArgumentOutOfRangeException(nameof(apiVersion)); + } + + if (httpVerb == HttpMethod.POST) + { + if (apiInfo.NeedsAuthentication) + { + requestBuilder.AddFormParameter("_sid", _sessionCache.Get(GenerateSessionCacheKey(settings), () => AuthenticateClient(settings), TimeSpan.FromHours(6))); + } + + requestBuilder.AddFormParameter("api", apiInfo.Name); + requestBuilder.AddFormParameter("version", apiVersion); + requestBuilder.AddFormParameter("method", methodName); + } + else + { + if (apiInfo.NeedsAuthentication) + { + requestBuilder.AddQueryParam("_sid", _sessionCache.Get(GenerateSessionCacheKey(settings), () => AuthenticateClient(settings), TimeSpan.FromHours(6))); + } + + requestBuilder.AddQueryParam("api", apiInfo.Name); + requestBuilder.AddQueryParam("version", apiVersion); + requestBuilder.AddQueryParam("method", methodName); + } + + return requestBuilder; + } + + private string GenerateInfoCacheKey(DownloadStationSettings settings, DiskStationApi api) + { + return $"{settings.Host}:{settings.Port}->{api}"; + } + + private void UpdateApiInfo(DownloadStationSettings settings) + { + var apis = new Dictionary() + { + { "SYNO.API.Auth", DiskStationApi.Auth }, + { _apiName, _apiType } + }; + + var requestBuilder = BuildRequest(settings, _apiInfo, "query", _apiInfo.MinVersion); + requestBuilder.AddQueryParam("query", string.Join(",", apis.Keys)); + + var infoResponse = ProcessRequest(requestBuilder, "get api info", _apiInfo.Type, settings); + + foreach (var data in infoResponse.Data) + { + if (apis.ContainsKey(data.Key)) + { + data.Value.Name = data.Key; + data.Value.Type = apis[data.Key]; + data.Value.NeedsAuthentication = apis[data.Key] != DiskStationApi.Auth; + + _infoCache.Set(GenerateInfoCacheKey(settings, apis[data.Key]), data.Value, TimeSpan.FromHours(1)); + } + } + } + + private DiskStationApiInfo GetApiInfo(DiskStationApi api, DownloadStationSettings settings) + { + if (api == DiskStationApi.Info) + { + return _apiInfo; + } + + var key = GenerateInfoCacheKey(settings, api); + var info = _infoCache.Find(key); + + if (info == null) + { + UpdateApiInfo(settings); + info = _infoCache.Find(key); + + if (info == null) + { + throw new DownloadClientException("Info of {0} not found on {1}:{2}", api, settings.Host, settings.Port); + } + } + + return info; + } + + public DiskStationApiInfo GetApiInfo(DownloadStationSettings settings) + { + return GetApiInfo(_apiType, settings); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs new file mode 100644 index 000000000..1723fcc80 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IDownloadStationInfoProxy : IDiskStationProxy + { + Dictionary GetConfig(DownloadStationSettings settings); + } + + public class DownloadStationInfoProxy : DiskStationProxyBase, IDownloadStationInfoProxy + { + public DownloadStationInfoProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + : base(DiskStationApi.DownloadStationInfo, "SYNO.DownloadStation.Info", httpClient, cacheManager, logger) + { + } + + public Dictionary GetConfig(DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "getConfig", 1); + + var response = ProcessRequest>(requestBuilder, "get config", settings); + + return response.Data; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs new file mode 100644 index 000000000..1e6849dac --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Clients.DownloadStation.Responses; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IDownloadStationTaskProxy : IDiskStationProxy + { + IEnumerable GetTasks(DownloadStationSettings settings); + void RemoveTask(string downloadId, DownloadStationSettings settings); + void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings); + void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings); + } + + public class DownloadStationTaskProxy : DiskStationProxyBase, IDownloadStationTaskProxy + { + public DownloadStationTaskProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + : base(DiskStationApi.DownloadStationTask, "SYNO.DownloadStation.Task", httpClient, cacheManager, logger) + { + } + + public void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "create", 2, HttpMethod.POST); + + if (downloadDirectory.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddFormParameter("destination", downloadDirectory); + } + + requestBuilder.AddFormUpload("file", filename, data); + + var response = ProcessRequest(requestBuilder, $"add task from data {filename}", settings); + } + + public void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "create", 3); + requestBuilder.AddQueryParam("uri", url); + + if (downloadDirectory.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("destination", downloadDirectory); + } + + var response = ProcessRequest(requestBuilder, $"add task from url {url}", settings); + } + + public IEnumerable GetTasks(DownloadStationSettings settings) + { + try + { + var requestBuilder = BuildRequest(settings, "list", 1); + requestBuilder.AddQueryParam("additional", "detail,transfer"); + + var response = ProcessRequest(requestBuilder, "get tasks", settings); + + return response.Data.Tasks; + } + catch (DownloadClientException e) + { + _logger.Error(e); + return new List(); + } + } + + public void RemoveTask(string downloadId, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "delete", 1); + requestBuilder.AddQueryParam("id", downloadId); + requestBuilder.AddQueryParam("force_complete", false); + + var response = ProcessRequest(requestBuilder, $"remove item {downloadId}", settings); + } + } +} 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..a07cc1b47 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs @@ -0,0 +1,44 @@ +using System.Linq; +using NLog; +using NzbDrone.Common.Cache; +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 : IDiskStationProxy + { + SharedFolderMapping GetSharedFolderMapping(string sharedFolder, DownloadStationSettings settings); + + FileStationListFileInfoResponse GetInfoFileOrDirectory(string path, DownloadStationSettings settings); + } + + public class FileStationProxy : DiskStationProxyBase, IFileStationProxy + { + public FileStationProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + : base(DiskStationApi.FileStationList, "SYNO.FileStation.List", httpClient, cacheManager, logger) + { + } + + 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 requestBuilder = BuildRequest(settings, "getinfo", 2); + requestBuilder.AddQueryParam("path", new[] { path }.ToJson()); + requestBuilder.AddQueryParam("additional", "[\"real_path\"]"); + + var response = ProcessRequest(requestBuilder, $"get info of {path}", settings); + + return response.Data.Files.First(); + } + } +} 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..50758d3af --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs @@ -0,0 +1,106 @@ +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 + { + { 160, "Permission denied. Give your user access to FileStation." }, + { 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]; + } + + if (CommonMessages.ContainsKey(Code)) + { + return CommonMessages[Code]; + } + + return $"{Code} - Unknown error"; + } + } +} 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..6c40ae75c --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationInfoResponse.cs @@ -0,0 +1,8 @@ +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..43c981669 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationResponse.cs @@ -0,0 +1,12 @@ +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..ebd79f3d7 --- /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..f31d51a68 --- /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..88a419d22 --- /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 readonly ILogger _logger; + private ICached _cache; + + 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..25ff176f6 --- /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 readonly ILogger _logger; + private ICached _cache; + + 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/DownloadStation/TorrentDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs new file mode 100644 index 000000000..dc759e9e5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class TorrentDownloadStation : TorrentClientBase + { + protected readonly IDownloadStationInfoProxy _dsInfoProxy; + protected readonly IDownloadStationTaskProxy _dsTaskProxy; + protected readonly ISharedFolderResolver _sharedFolderResolver; + protected readonly ISerialNumberProvider _serialNumberProvider; + protected readonly IFileStationProxy _fileStationProxy; + + public TorrentDownloadStation(ISharedFolderResolver sharedFolderResolver, + ISerialNumberProvider serialNumberProvider, + IFileStationProxy fileStationProxy, + IDownloadStationInfoProxy dsInfoProxy, + IDownloadStationTaskProxy dsTaskProxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + _dsInfoProxy = dsInfoProxy; + _dsTaskProxy = dsTaskProxy; + _fileStationProxy = fileStationProxy; + _sharedFolderResolver = sharedFolderResolver; + _serialNumberProvider = serialNumberProvider; + } + + public override string Name => "Download Station"; + + public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to connect to Download Station if 2-Factor Authentication is enabled on your DSM account", ProviderMessageType.Warning); + + protected IEnumerable GetTasks() + { + return _dsTaskProxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.BT.ToString().ToLower()); + } + + protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + { + var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); + + _dsTaskProxy.AddTaskFromUrl(magnetLink, GetDownloadDirectory(), Settings); + + var item = GetTasks().SingleOrDefault(t => t.Additional.Detail["uri"] == magnetLink); + + if (item != null) + { + _logger.Debug("{0} added correctly", release); + 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(ReleaseInfo release, string hash, string filename, byte[] fileContent) + { + var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); + + _dsTaskProxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); + + var items = GetTasks().Where(t => t.Additional.Detail["uri"] == Path.GetFileNameWithoutExtension(filename)); + + var item = items.SingleOrDefault(); + + if (item != null) + { + _logger.Debug("{0} added correctly", release); + 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.HasErrors()) + { + return; + } + + failures.AddIfNotNull(TestOutputPath()); + } + + protected bool IsFinished(DownloadStationTask torrent) + { + return torrent.Status == DownloadStationTaskStatus.Finished; + } + + protected bool IsCompleted(DownloadStationTask torrent) + { + return torrent.Status == DownloadStationTaskStatus.Seeding || IsFinished(torrent) || (torrent.Status == DownloadStationTaskStatus.Waiting && torrent.Size != 0 && GetRemainingSize(torrent) <= 0); + } + + protected string GetMessage(DownloadStationTask 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 long GetRemainingSize(DownloadStationTask 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(DownloadStationTask 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 double? GetSeedRatio(DownloadStationTask torrent) + { + var downloaded = torrent.Additional.Transfer["size_downloaded"].ParseInt64(); + var uploaded = torrent.Additional.Transfer["size_uploaded"].ParseInt64(); + + if (downloaded.HasValue && uploaded.HasValue) + { + return downloaded <= 0 ? 0 : (double)uploaded.Value / downloaded.Value; + } + + return null; + } + + protected ValidationFailure TestOutputPath() + { + try + { + var downloadDir = GetDefaultDir(); + + if (downloadDir == null) + { + return new NzbDroneValidationFailure(nameof(Settings.TvDirectory), "No default destination") + { + DetailedDescription = $"You must login into your Diskstation as {Settings.Username} and manually set it up into DownloadStation settings under BT/HTTP/FTP/NZB -> Location." + }; + } + + downloadDir = GetDownloadDirectory(); + + if (downloadDir != null) + { + var sharedFolder = downloadDir.Split('\\', '/')[0]; + var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.TvCategory); + + var folderInfo = _fileStationProxy.GetInfoFileOrDirectory($"/{downloadDir}", Settings); + + if (folderInfo.Additional == null) + { + return new NzbDroneValidationFailure(fieldName, $"Shared folder does not exist") + { + DetailedDescription = $"The Diskstation does not have a Shared Folder with the name '{sharedFolder}', are you sure you specified it correctly?" + }; + } + + if (!folderInfo.IsDir) + { + return new NzbDroneValidationFailure(fieldName, $"Folder does not exist") + { + DetailedDescription = $"The folder '{downloadDir}' does not exist, it must be created manually inside the Shared Folder '{sharedFolder}'." + }; + } + } + + return null; + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure(string.Empty, ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex, "Error testing Torrent Download Station"); + return new NzbDroneValidationFailure(string.Empty, $"Unknown exception: {ex.Message}"); + } + } + + 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 Prowlarr isn't blocked from accessing {Name} by WhiteList limitations in the {Name} configuration." + }; + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to connect to Torrent Download Station"); + + 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, "Error testing Torrent Download Station"); + + return new NzbDroneValidationFailure("Host", "Unable to connect to Torrent Download Station") + { + DetailedDescription = ex.Message + }; + } + } + + protected ValidationFailure ValidateVersion() + { + var info = _dsTaskProxy.GetApiInfo(Settings); + + _logger.Debug("Download Station api version information: Min {0} - Max {1}", info.MinVersion, info.MaxVersion); + + if (info.MinVersion > 2 || info.MaxVersion < 2) + { + return new ValidationFailure(string.Empty, $"Download Station API version not supported, should be at least 2. It supports from {info.MinVersion} to {info.MaxVersion}"); + } + + return null; + } + + protected string ParseDownloadId(string id) + { + return id.Split(':')[1]; + } + + protected string CreateDownloadId(string id, string hashedSerialNumber) + { + return $"{hashedSerialNumber}:{id}"; + } + + protected string GetDefaultDir() + { + var config = _dsInfoProxy.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; + } + + protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs new file mode 100644 index 000000000..1cba414c1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.DownloadStation +{ + public class UsenetDownloadStation : UsenetClientBase + { + protected readonly IDownloadStationInfoProxy _dsInfoProxy; + protected readonly IDownloadStationTaskProxy _dsTaskProxy; + protected readonly ISharedFolderResolver _sharedFolderResolver; + protected readonly ISerialNumberProvider _serialNumberProvider; + protected readonly IFileStationProxy _fileStationProxy; + + public UsenetDownloadStation(ISharedFolderResolver sharedFolderResolver, + ISerialNumberProvider serialNumberProvider, + IFileStationProxy fileStationProxy, + IDownloadStationInfoProxy dsInfoProxy, + IDownloadStationTaskProxy dsTaskProxy, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IValidateNzbs nzbValidationService, + Logger logger) + : base(httpClient, configService, diskProvider, nzbValidationService, logger) + { + _dsInfoProxy = dsInfoProxy; + _dsTaskProxy = dsTaskProxy; + _fileStationProxy = fileStationProxy; + _sharedFolderResolver = sharedFolderResolver; + _serialNumberProvider = serialNumberProvider; + } + + public override string Name => "Download Station"; + + public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to connect to Download Station if 2-Factor Authentication is enabled on your DSM account", ProviderMessageType.Warning); + + protected IEnumerable GetTasks() + { + return _dsTaskProxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.NZB.ToString().ToLower()); + } + + protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContent) + { + var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); + + _dsTaskProxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); + + var items = GetTasks().Where(t => t.Additional.Detail["uri"] == filename); + + var item = items.SingleOrDefault(); + + if (item != null) + { + _logger.Debug("{0} added correctly", release); + return CreateDownloadId(item.Id, hashedSerialNumber); + } + + _logger.Debug("No such task {0} in Download Station", filename); + + throw new DownloadClientException("Failed to add NZB task to Download Station"); + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.HasErrors()) + { + return; + } + + failures.AddIfNotNull(TestOutputPath()); + } + + protected ValidationFailure TestOutputPath() + { + try + { + var downloadDir = GetDefaultDir(); + + if (downloadDir == null) + { + return new NzbDroneValidationFailure(nameof(Settings.TvDirectory), "No default destination") + { + DetailedDescription = $"You must login into your Diskstation as {Settings.Username} and manually set it up into DownloadStation settings under BT/HTTP/FTP/NZB -> Location." + }; + } + + downloadDir = GetDownloadDirectory(); + + if (downloadDir != null) + { + var sharedFolder = downloadDir.Split('\\', '/')[0]; + var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.TvCategory); + + var folderInfo = _fileStationProxy.GetInfoFileOrDirectory($"/{downloadDir}", Settings); + + if (folderInfo.Additional == null) + { + return new NzbDroneValidationFailure(fieldName, $"Shared folder does not exist") + { + DetailedDescription = $"The Diskstation does not have a Shared Folder with the name '{sharedFolder}', are you sure you specified it correctly?" + }; + } + + if (!folderInfo.IsDir) + { + return new NzbDroneValidationFailure(fieldName, $"Folder does not exist") + { + DetailedDescription = $"The folder '{downloadDir}' does not exist, it must be created manually inside the Shared Folder '{sharedFolder}'." + }; + } + } + + return null; + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure(string.Empty, ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex, "Error testing Usenet Download Station"); + return new NzbDroneValidationFailure(string.Empty, $"Unknown exception: {ex.Message}"); + } + } + + 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 Prowlarr isn't blocked from accessing {Name} by WhiteList limitations in the {Name} configuration." + }; + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to connect to Usenet Download Station"); + + 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, "Error testing Torrent Download Station"); + + return new NzbDroneValidationFailure("Host", "Unable to connect to Usenet Download Station") + { + DetailedDescription = ex.Message + }; + } + } + + protected ValidationFailure ValidateVersion() + { + var info = _dsTaskProxy.GetApiInfo(Settings); + + _logger.Debug("Download Station api version information: Min {0} - Max {1}", info.MinVersion, info.MaxVersion); + + if (info.MinVersion > 2 || info.MaxVersion < 2) + { + return new ValidationFailure(string.Empty, $"Download Station API version not supported, should be at least 2. It supports from {info.MinVersion} to {info.MaxVersion}"); + } + + return null; + } + + protected string GetMessage(DownloadStationTask task) + { + if (task.StatusExtra != null) + { + if (task.Status == DownloadStationTaskStatus.Extracting) + { + return $"Extracting: {int.Parse(task.StatusExtra["unzip_progress"])}%"; + } + + if (task.Status == DownloadStationTaskStatus.Error) + { + return task.StatusExtra["error_detail"]; + } + } + + return null; + } + + protected long GetRemainingSize(DownloadStationTask task) + { + var downloadedString = task.Additional.Transfer["size_downloaded"]; + long downloadedSize; + + if (downloadedString.IsNullOrWhiteSpace() || !long.TryParse(downloadedString, out downloadedSize)) + { + _logger.Debug("Task {0} has invalid size_downloaded: {1}", task.Title, downloadedString); + downloadedSize = 0; + } + + return task.Size - Math.Max(0, downloadedSize); + } + + protected long GetDownloadSpeed(DownloadStationTask task) + { + var speedString = task.Additional.Transfer["speed_download"]; + long downloadSpeed; + + if (speedString.IsNullOrWhiteSpace() || !long.TryParse(speedString, out downloadSpeed)) + { + _logger.Debug("Task {0} has invalid speed_download: {1}", task.Title, speedString); + downloadSpeed = 0; + } + + return Math.Max(downloadSpeed, 0); + } + + protected TimeSpan? GetRemainingTime(long remainingSize, long downloadSpeed) + { + if (downloadSpeed > 0) + { + return TimeSpan.FromSeconds(remainingSize / downloadSpeed); + } + else + { + return null; + } + } + + protected string ParseDownloadId(string id) + { + return id.Split(':')[1]; + } + + protected string CreateDownloadId(string id, string hashedSerialNumber) + { + return $"{hashedSerialNumber}:{id}"; + } + + protected string GetDefaultDir() + { + var config = _dsInfoProxy.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; + } + + protected override string AddFromLink(ReleaseInfo release) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs b/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs new file mode 100644 index 000000000..8698500ea --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.Flood.Models; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download.Clients.Flood +{ + public class Flood : TorrentClientBase + { + private readonly IFloodProxy _proxy; + + public Flood(IFloodProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + _proxy = proxy; + } + + private static IEnumerable HandleTags(ReleaseInfo release, FloodSettings settings) + { + var result = new HashSet(); + + if (settings.Tags.Any()) + { + result.UnionWith(settings.Tags); + } + + if (settings.AdditionalTags.Any()) + { + foreach (var additionalTag in settings.AdditionalTags) + { + switch (additionalTag) + { + case (int)AdditionalTags.Indexer: + result.Add(release.Indexer); + break; + default: + throw new DownloadClientException("Unexpected additional tag ID"); + } + } + } + + return result; + } + + public override string Name => "Flood"; + public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to remove torrents that have finished seeding when using Flood", ProviderMessageType.Warning); + + protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + { + _proxy.AddTorrentByFile(Convert.ToBase64String(fileContent), HandleTags(release, Settings), Settings); + + return hash; + } + + protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + { + _proxy.AddTorrentByUrl(magnetLink, HandleTags(release, Settings), Settings); + + return hash; + } + + protected override void Test(List failures) + { + try + { + _proxy.AuthVerify(Settings); + } + catch (DownloadClientAuthenticationException ex) + { + failures.Add(new ValidationFailure("Password", ex.Message)); + } + catch (Exception ex) + { + failures.Add(new ValidationFailure("Host", ex.Message)); + } + } + + protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs b/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs new file mode 100644 index 000000000..ddebdbfff --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs @@ -0,0 +1,213 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.Flood.Types; + +namespace NzbDrone.Core.Download.Clients.Flood +{ + public interface IFloodProxy + { + void AuthVerify(FloodSettings settings); + void AddTorrentByUrl(string url, IEnumerable tags, FloodSettings settings); + void AddTorrentByFile(string file, IEnumerable tags, FloodSettings settings); + void DeleteTorrent(string hash, bool deleteData, FloodSettings settings); + Dictionary GetTorrents(FloodSettings settings); + List GetTorrentContentPaths(string hash, FloodSettings settings); + void SetTorrentsTags(string hash, IEnumerable tags, FloodSettings settings); + } + + public class FloodProxy : IFloodProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly ICached> _authCookieCache; + + public FloodProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + private string BuildUrl(FloodSettings settings) + { + return $"{(settings.UseSsl ? "https://" : "http://")}{settings.Host}:{settings.Port}/{settings.UrlBase}"; + } + + private string BuildCachedCookieKey(FloodSettings settings) + { + return $"{BuildUrl(settings)}:{settings.Username}"; + } + + private HttpRequestBuilder BuildRequest(FloodSettings settings) + { + var requestBuilder = new HttpRequestBuilder(HttpUri.CombinePath(BuildUrl(settings), "/api")) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; + + requestBuilder.Headers.ContentType = "application/json"; + requestBuilder.SetCookies(AuthAuthenticate(requestBuilder, settings)); + + return requestBuilder; + } + + private HttpResponse HandleRequest(HttpRequest request, FloodSettings settings) + { + try + { + return _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden || + ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _authCookieCache.Remove(BuildCachedCookieKey(settings)); + throw new DownloadClientAuthenticationException("Failed to authenticate with Flood."); + } + + throw new DownloadClientException("Unable to connect to Flood, please check your settings"); + } + catch + { + throw new DownloadClientException("Unable to connect to Flood, please check your settings"); + } + } + + private Dictionary AuthAuthenticate(HttpRequestBuilder requestBuilder, FloodSettings settings, bool force = false) + { + var cachedCookies = _authCookieCache.Find(BuildCachedCookieKey(settings)); + + if (cachedCookies == null || force) + { + var authenticateRequest = requestBuilder.Resource("/auth/authenticate").Post().Build(); + + var body = new Dictionary + { + { "username", settings.Username }, + { "password", settings.Password } + }; + authenticateRequest.SetContent(body.ToJson()); + + var response = HandleRequest(authenticateRequest, settings); + cachedCookies = response.GetCookies(); + _authCookieCache.Set(BuildCachedCookieKey(settings), cachedCookies); + } + + return cachedCookies; + } + + public void AuthVerify(FloodSettings settings) + { + var verifyRequest = BuildRequest(settings).Resource("/auth/verify").Build(); + + verifyRequest.Method = HttpMethod.GET; + + HandleRequest(verifyRequest, settings); + } + + public void AddTorrentByFile(string file, IEnumerable tags, FloodSettings settings) + { + var addRequest = BuildRequest(settings).Resource("/torrents/add-files").Post().Build(); + + var body = new Dictionary + { + { "files", new List { file } }, + { "tags", tags.ToList() } + }; + + if (settings.Destination != null) + { + body.Add("destination", settings.Destination); + } + + if (!settings.AddPaused) + { + body.Add("start", true); + } + + addRequest.SetContent(body.ToJson()); + + HandleRequest(addRequest, settings); + } + + public void AddTorrentByUrl(string url, IEnumerable tags, FloodSettings settings) + { + var addRequest = BuildRequest(settings).Resource("/torrents/add-urls").Post().Build(); + + var body = new Dictionary + { + { "urls", new List { url } }, + { "tags", tags.ToList() } + }; + + if (settings.Destination != null) + { + body.Add("destination", settings.Destination); + } + + if (!settings.AddPaused) + { + body.Add("start", true); + } + + addRequest.SetContent(body.ToJson()); + + HandleRequest(addRequest, settings); + } + + public void DeleteTorrent(string hash, bool deleteData, FloodSettings settings) + { + var deleteRequest = BuildRequest(settings).Resource("/torrents/delete").Post().Build(); + + var body = new Dictionary + { + { "hashes", new List { hash } }, + { "deleteData", deleteData } + }; + deleteRequest.SetContent(body.ToJson()); + + HandleRequest(deleteRequest, settings); + } + + public Dictionary GetTorrents(FloodSettings settings) + { + var getTorrentsRequest = BuildRequest(settings).Resource("/torrents").Build(); + + getTorrentsRequest.Method = HttpMethod.GET; + + return Json.Deserialize(HandleRequest(getTorrentsRequest, settings).Content).Torrents; + } + + public List GetTorrentContentPaths(string hash, FloodSettings settings) + { + var contentsRequest = BuildRequest(settings).Resource($"/torrents/{hash}/contents").Build(); + + contentsRequest.Method = HttpMethod.GET; + + return Json.Deserialize>(HandleRequest(contentsRequest, settings).Content).ConvertAll(content => content.Path); + } + + public void SetTorrentsTags(string hash, IEnumerable tags, FloodSettings settings) + { + var tagsRequest = BuildRequest(settings).Resource("/torrents/tags").Build(); + + tagsRequest.Method = HttpMethod.PATCH; + + var body = new Dictionary + { + { "hashes", new List { hash } }, + { "tags", tags.ToList() } + }; + tagsRequest.SetContent(body.ToJson()); + + HandleRequest(tagsRequest, settings); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs b/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs new file mode 100644 index 000000000..46f6f19cb --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Download.Clients.Flood.Models; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Flood +{ + public class FloodSettingsValidator : AbstractValidator + { + public FloodSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + } + } + + public class FloodSettings : IProviderConfig + { + private static readonly FloodSettingsValidator Validator = new FloodSettingsValidator(); + + public FloodSettings() + { + UseSsl = false; + Host = "localhost"; + Port = 3000; + Tags = new string[] + { + "prowlarr" + }; + AdditionalTags = Enumerable.Empty(); + AddPaused = false; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Flood")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, HelpText = "Optionally adds a prefix to Flood API, such as [protocol]://[host]:[port]/[urlBase]api")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Destination", Type = FieldType.Textbox, HelpText = "Manually specifies download destination")] + public string Destination { get; set; } + + [FieldDefinition(7, Label = "Tags", Type = FieldType.Tag, HelpText = "Initial tags of a download. To be recognized, a download must have all initial tags. This avoids conflicts with unrelated downloads.")] + public IEnumerable Tags { get; set; } + + [FieldDefinition(8, Label = "Additional Tags", Type = FieldType.Select, SelectOptions = typeof(AdditionalTags), HelpText = "Adds properties of media as tags. Hints are examples.", Advanced = true)] + public IEnumerable AdditionalTags { get; set; } + + [FieldDefinition(9, Label = "Add Paused", Type = FieldType.Checkbox)] + public bool AddPaused { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Models/AdditionalTags.cs b/src/NzbDrone.Core/Download/Clients/Flood/Models/AdditionalTags.cs new file mode 100644 index 000000000..f8eba17b7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Models/AdditionalTags.cs @@ -0,0 +1,28 @@ +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.Download.Clients.Flood.Models +{ + public enum AdditionalTags + { + [FieldOption(Hint = "Big Buck Bunny Series")] + Collection = 0, + + [FieldOption(Hint = "Bluray-2160p")] + Quality = 1, + + [FieldOption(Hint = "English")] + Languages = 2, + + [FieldOption(Hint = "Example-Raws")] + ReleaseGroup = 3, + + [FieldOption(Hint = "2020")] + Year = 4, + + [FieldOption(Hint = "Torznab")] + Indexer = 5, + + [FieldOption(Hint = "C-SPAN")] + Studio = 6 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs b/src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs new file mode 100644 index 000000000..3f3500307 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Flood.Types +{ + public sealed class Torrent + { + [JsonProperty(PropertyName = "bytesDone")] + public long BytesDone { get; set; } + + [JsonProperty(PropertyName = "directory")] + public string Directory { get; set; } + + [JsonProperty(PropertyName = "eta")] + public long Eta { get; set; } + + [JsonProperty(PropertyName = "message")] + public string Message { get; set; } + + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + + [JsonProperty(PropertyName = "ratio")] + public float Ratio { get; set; } + + [JsonProperty(PropertyName = "sizeBytes")] + public long SizeBytes { get; set; } + + [JsonProperty(PropertyName = "status")] + public List Status { get; set; } + + [JsonProperty(PropertyName = "tags")] + public List Tags { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentContent.cs b/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentContent.cs new file mode 100644 index 000000000..6dfd7cb98 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentContent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Flood.Types +{ + public sealed class TorrentContent + { + [JsonProperty(PropertyName = "path")] + public string Path { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentListSummary.cs b/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentListSummary.cs new file mode 100644 index 000000000..2d81cfba7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Flood/Types/TorrentListSummary.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Flood.Types +{ + public sealed class TorrentListSummary + { + [JsonProperty(PropertyName = "id")] + public long Id { get; set; } + + [JsonProperty(PropertyName = "torrents")] + public Dictionary Torrents { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs new file mode 100644 index 000000000..0ae4ae497 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.Hadouken.Models; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public class Hadouken : TorrentClientBase + { + private readonly IHadoukenProxy _proxy; + + public Hadouken(IHadoukenProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + _proxy = proxy; + } + + public override string Name => "Hadouken"; + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.HasErrors()) + { + return; + } + + failures.AddIfNotNull(TestGetTorrents()); + } + + protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + { + _proxy.AddTorrentUri(Settings, magnetLink); + + return hash.ToUpper(); + } + + protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + { + return _proxy.AddTorrentFile(Settings, fileContent).ToUpper(); + } + + private ValidationFailure TestConnection() + { + try + { + var sysInfo = _proxy.GetSystemInfo(Settings); + var version = new Version(sysInfo.Versions["hadouken"]); + + if (version < new Version("5.1")) + { + return new ValidationFailure(string.Empty, + "Old Hadouken client with unsupported API, need 5.1 or higher"); + } + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + + return new NzbDroneValidationFailure("Password", "Authentication failed"); + } + catch (Exception ex) + { + return new NzbDroneValidationFailure("Host", "Unable to connect to Hadouken") + { + DetailedDescription = 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; + } + + protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs new file mode 100644 index 000000000..b78a66df5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.Hadouken.Models; + +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public interface IHadoukenProxy + { + HadoukenSystemInfo GetSystemInfo(HadoukenSettings settings); + HadoukenTorrent[] GetTorrents(HadoukenSettings settings); + IReadOnlyDictionary GetConfig(HadoukenSettings settings); + string AddTorrentFile(HadoukenSettings settings, byte[] fileContent); + void AddTorrentUri(HadoukenSettings settings, string torrentUrl); + void RemoveTorrent(HadoukenSettings settings, string downloadId); + void RemoveTorrentAndData(HadoukenSettings settings, string downloadId); + } + + public class HadoukenProxy : IHadoukenProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public HadoukenProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public HadoukenSystemInfo GetSystemInfo(HadoukenSettings settings) + { + return ProcessRequest(settings, "core.getSystemInfo"); + } + + public HadoukenTorrent[] GetTorrents(HadoukenSettings settings) + { + var result = ProcessRequest(settings, "webui.list"); + + return GetTorrents(result.Torrents); + } + + public IReadOnlyDictionary GetConfig(HadoukenSettings settings) + { + return ProcessRequest>(settings, "webui.getSettings"); + } + + public string AddTorrentFile(HadoukenSettings settings, byte[] fileContent) + { + return ProcessRequest(settings, "webui.addTorrent", "file", Convert.ToBase64String(fileContent), new { label = settings.Category }); + } + + public void AddTorrentUri(HadoukenSettings settings, string torrentUrl) + { + ProcessRequest(settings, "webui.addTorrent", "url", torrentUrl, new { label = settings.Category }); + } + + public void RemoveTorrent(HadoukenSettings settings, string downloadId) + { + ProcessRequest(settings, "webui.perform", "remove", new string[] { downloadId }); + } + + public void RemoveTorrentAndData(HadoukenSettings settings, string downloadId) + { + ProcessRequest(settings, "webui.perform", "removedata", new string[] { downloadId }); + } + + private T ProcessRequest(HadoukenSettings settings, string method, params object[] parameters) + { + var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + baseUrl = HttpUri.CombinePath(baseUrl, "api"); + var requestBuilder = new JsonRpcRequestBuilder(baseUrl, method, parameters); + requestBuilder.LogResponseContent = true; + requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + requestBuilder.Headers.Add("Accept-Encoding", "gzip,deflate"); + + var httpRequest = requestBuilder.Build(); + HttpResponse response; + + try + { + response = _httpClient.Execute(httpRequest); + } + catch (HttpException ex) + { + throw new DownloadClientException("Unable to connect to Hadouken, please check your settings", ex); + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to Hadouken, certificate validation failed.", ex); + } + + throw new DownloadClientUnavailableException("Unable to connect to Hadouken, please check your settings", ex); + } + + var result = Json.Deserialize>(response.Content); + + if (result.Error != null) + { + throw new DownloadClientException("Error response received from Hadouken: {0}", result.Error.ToString()); + } + + return result.Result; + } + + private HadoukenTorrent[] GetTorrents(object[][] torrentsRaw) + { + if (torrentsRaw == null) + { + return Array.Empty(); + } + + var torrents = new List(); + + foreach (var item in torrentsRaw) + { + var torrent = MapTorrent(item); + if (torrent != null) + { + torrent.IsFinished = torrent.Progress >= 1000; + torrents.Add(torrent); + } + } + + return torrents.ToArray(); + } + + private HadoukenTorrent MapTorrent(object[] item) + { + HadoukenTorrent torrent = null; + + try + { + torrent = new HadoukenTorrent() + { + InfoHash = Convert.ToString(item[0]), + State = ParseState(Convert.ToInt32(item[1])), + Name = Convert.ToString(item[2]), + TotalSize = Convert.ToInt64(item[3]), + Progress = Convert.ToDouble(item[4]), + DownloadedBytes = Convert.ToInt64(item[5]), + UploadedBytes = Convert.ToInt64(item[6]), + DownloadRate = Convert.ToInt64(item[9]), + Label = Convert.ToString(item[11]), + Error = Convert.ToString(item[21]), + SavePath = Convert.ToString(item[26]) + }; + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to map Hadouken torrent data."); + } + + return torrent; + } + + private HadoukenTorrentState ParseState(int state) + { + if ((state & 1) == 1) + { + return HadoukenTorrentState.Downloading; + } + else if ((state & 2) == 2) + { + return HadoukenTorrentState.CheckingFiles; + } + else if ((state & 32) == 32) + { + return HadoukenTorrentState.Paused; + } + else if ((state & 64) == 64) + { + return HadoukenTorrentState.QueuedForChecking; + } + + return HadoukenTorrentState.Unknown; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs new file mode 100644 index 000000000..df8ecfe38 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs @@ -0,0 +1,62 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Hadouken +{ + public class HadoukenSettingsValidator : AbstractValidator + { + public HadoukenSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + + RuleFor(c => c.Username).NotEmpty() + .WithMessage("Username must not be empty."); + + RuleFor(c => c.Password).NotEmpty() + .WithMessage("Password must not be empty."); + } + } + + public class HadoukenSettings : IProviderConfig + { + private static readonly HadoukenSettingsValidator Validator = new HadoukenSettingsValidator(); + + public HadoukenSettings() + { + Host = "localhost"; + Port = 7070; + Category = "prowlarr"; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Hadouken")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the Hadouken url, e.g. http://[host]:[port]/[urlBase]/api")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox)] + public string Category { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenSystemInfo.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenSystemInfo.cs new file mode 100644 index 000000000..6d3296efb --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenSystemInfo.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Hadouken.Models +{ + public sealed class HadoukenSystemInfo + { + public string Commitish { get; set; } + public string Branch { get; set; } + public Dictionary Versions { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs new file mode 100644 index 000000000..b84c2b3f5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs @@ -0,0 +1,20 @@ +namespace NzbDrone.Core.Download.Clients.Hadouken.Models +{ + public sealed class HadoukenTorrent + { + public string InfoHash { get; set; } + public double Progress { get; set; } + public string Name { get; set; } + public string Label { get; set; } + public string SavePath { get; set; } + public HadoukenTorrentState State { get; set; } + public bool IsFinished { get; set; } + public bool IsPaused { get; set; } + public bool IsSeeding { get; set; } + public long TotalSize { get; set; } + public long DownloadedBytes { get; set; } + public long UploadedBytes { get; set; } + public long DownloadRate { get; set; } + public string Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentResponse.cs new file mode 100644 index 000000000..1314cda0c --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.Hadouken.Models +{ + public class HadoukenTorrentResponse + { + public object[][] Torrents { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentState.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentState.cs new file mode 100644 index 000000000..06551476d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrentState.cs @@ -0,0 +1,16 @@ +namespace NzbDrone.Core.Download.Clients.Hadouken.Models +{ + public enum HadoukenTorrentState + { + Unknown = 0, + QueuedForChecking = 1, + CheckingFiles = 2, + DownloadingMetadata = 3, + Downloading = 4, + Finished = 5, + Seeding = 6, + Allocating = 7, + CheckingResumeData = 8, + Paused = 9 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexLoginResultTypeConverter.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexLoginResultTypeConverter.cs new file mode 100644 index 000000000..e74b8f973 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexLoginResultTypeConverter.cs @@ -0,0 +1,29 @@ +using System; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.JsonConverters +{ + public class NzbVortexLoginResultTypeConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var priorityType = (NzbVortexLoginResultType)value; + writer.WriteValue(priorityType.ToString().ToLower()); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = reader.Value.ToString().Replace("_", string.Empty); + + NzbVortexLoginResultType output; + Enum.TryParse(result, true, out output); + + return output; + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(NzbVortexLoginResultType); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexResultTypeConverter.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexResultTypeConverter.cs new file mode 100644 index 000000000..bd63788bc --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/JsonConverters/NzbVortexResultTypeConverter.cs @@ -0,0 +1,29 @@ +using System; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.JsonConverters +{ + public class NzbVortexResultTypeConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var priorityType = (NzbVortexResultType)value; + writer.WriteValue(priorityType.ToString().ToLower()); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = reader.Value.ToString().Replace("_", string.Empty); + + NzbVortexResultType output; + Enum.TryParse(result, true, out output); + + return output; + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(NzbVortexResultType); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs new file mode 100644 index 000000000..f4d4ef794 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; +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.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public class NzbVortex : UsenetClientBase + { + private readonly INzbVortexProxy _proxy; + + public NzbVortex(INzbVortexProxy proxy, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IValidateNzbs nzbValidationService, + Logger logger) + : base(httpClient, configService, diskProvider, nzbValidationService, logger) + { + _proxy = proxy; + } + + protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContents) + { + var priority = Settings.Priority; + + var response = _proxy.DownloadNzb(fileContents, filename, priority, Settings); + + if (response == null) + { + throw new DownloadClientException("Failed to add nzb {0}", filename); + } + + return response; + } + + public override string Name => "NZBVortex"; + + protected List GetGroups() + { + return _proxy.GetGroups(Settings); + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + failures.AddIfNotNull(TestApiVersion()); + failures.AddIfNotNull(TestAuthentication()); + failures.AddIfNotNull(TestCategory()); + } + + private ValidationFailure TestConnection() + { + try + { + _proxy.GetVersion(Settings); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to connect to NZBVortex"); + + return new NzbDroneValidationFailure("Host", "Unable to connect to NZBVortex") + { + DetailedDescription = ex.Message + }; + } + + return null; + } + + private ValidationFailure TestApiVersion() + { + try + { + var response = _proxy.GetApiVersion(Settings); + var version = new Version(response.ApiLevel); + + if (version.Major < 2 || (version.Major == 2 && version.Minor < 3)) + { + return new ValidationFailure("Host", "NZBVortex needs to be updated"); + } + } + catch (Exception ex) + { + _logger.Error(ex, ex.Message); + return new ValidationFailure("Host", "Unable to connect to NZBVortex"); + } + + return null; + } + + private ValidationFailure TestAuthentication() + { + try + { + _proxy.GetQueue(1, Settings); + } + catch (NzbVortexAuthenticationException) + { + return new ValidationFailure("ApiKey", "API Key Incorrect"); + } + + return null; + } + + private ValidationFailure TestCategory() + { + var group = GetGroups().FirstOrDefault(c => c.GroupName == Settings.Category); + + if (group == null) + { + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + return new NzbDroneValidationFailure("Category", "Group does not exist") + { + DetailedDescription = "The Group you entered doesn't exist in NzbVortex. Go to NzbVortex to create it." + }; + } + } + + return null; + } + + protected override string AddFromLink(ReleaseInfo release) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexAuthenticationException.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexAuthenticationException.cs new file mode 100644 index 000000000..6c8d3e34b --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexAuthenticationException.cs @@ -0,0 +1,27 @@ +using System; + +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + internal class NzbVortexAuthenticationException : DownloadClientException + { + public NzbVortexAuthenticationException(string message, params object[] args) + : base(message, args) + { + } + + public NzbVortexAuthenticationException(string message) + : base(message) + { + } + + public NzbVortexAuthenticationException(string message, Exception innerException, params object[] args) + : base(message, innerException, args) + { + } + + public NzbVortexAuthenticationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexFile.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexFile.cs new file mode 100644 index 000000000..b0f0c7d1f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexFile.cs @@ -0,0 +1,16 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public class NzbVortexFile + { + public int Id { get; set; } + public string FileName { get; set; } + public NzbVortexStateType State { get; set; } + public long DileSize { get; set; } + public long DownloadedSize { get; set; } + public long TotalDownloadedSize { get; set; } + public bool ExtractPasswordRequired { get; set; } + public string ExtractPassword { get; set; } + public long PostDate { get; set; } + public bool Crc32CheckFailed { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexGroup.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexGroup.cs new file mode 100644 index 000000000..5839dcba9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexGroup.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public class NzbVortexGroup + { + public string GroupName { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexJsonError.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexJsonError.cs new file mode 100644 index 000000000..487c8390e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexJsonError.cs @@ -0,0 +1,13 @@ +using System; + +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public class NzbVortexJsonError + { + public string Status { get; set; } + public string Error { get; set; } + + public bool Failed => !string.IsNullOrWhiteSpace(Status) && + Status.Equals("false", StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexLoginResultType.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexLoginResultType.cs new file mode 100644 index 000000000..e239df638 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexLoginResultType.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public enum NzbVortexLoginResultType + { + Successful, + Failed + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexNotLoggedInException.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexNotLoggedInException.cs new file mode 100644 index 000000000..8735cb383 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexNotLoggedInException.cs @@ -0,0 +1,32 @@ +using System; + +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + internal class NzbVortexNotLoggedInException : DownloadClientException + { + public NzbVortexNotLoggedInException() + : this("Authentication is required") + { + } + + public NzbVortexNotLoggedInException(string message, params object[] args) + : base(message, args) + { + } + + public NzbVortexNotLoggedInException(string message) + : base(message) + { + } + + public NzbVortexNotLoggedInException(string message, Exception innerException, params object[] args) + : base(message, innerException, args) + { + } + + public NzbVortexNotLoggedInException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexPriority.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexPriority.cs new file mode 100644 index 000000000..44e18b54e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexPriority.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public enum NzbVortexPriority + { + Low = -1, + Normal = 0, + High = 1, + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs new file mode 100644 index 000000000..aeb2d7ac5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Net; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.NzbVortex.Responses; + +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public interface INzbVortexProxy + { + string DownloadNzb(byte[] nzbData, string filename, int priority, NzbVortexSettings settings); + void Remove(int id, bool deleteData, NzbVortexSettings settings); + NzbVortexVersionResponse GetVersion(NzbVortexSettings settings); + NzbVortexApiVersionResponse GetApiVersion(NzbVortexSettings settings); + List GetGroups(NzbVortexSettings settings); + List GetQueue(int doneLimit, NzbVortexSettings settings); + List GetFiles(int id, NzbVortexSettings settings); + } + + public class NzbVortexProxy : INzbVortexProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + private readonly ICached _authSessionIdCache; + + public NzbVortexProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authSessionIdCache = cacheManager.GetCache(GetType(), "authCache"); + } + + public string DownloadNzb(byte[] nzbData, string filename, int priority, NzbVortexSettings settings) + { + var requestBuilder = BuildRequest(settings).Resource("nzb/add") + .Post() + .AddQueryParam("priority", priority.ToString()); + + if (settings.Category.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("groupname", settings.Category); + } + + requestBuilder.AddFormUpload("name", filename, nzbData, "application/x-nzb"); + + var response = ProcessRequest(requestBuilder, true, settings); + + return response.Id; + } + + public void Remove(int id, bool deleteData, NzbVortexSettings settings) + { + var requestBuilder = BuildRequest(settings).Resource(string.Format("nzb/{0}/{1}", id, deleteData ? "cancelDelete" : "cancel")); + + ProcessRequest(requestBuilder, true, settings); + } + + public NzbVortexVersionResponse GetVersion(NzbVortexSettings settings) + { + var requestBuilder = BuildRequest(settings).Resource("app/appversion"); + + var response = ProcessRequest(requestBuilder, false, settings); + + return response; + } + + public NzbVortexApiVersionResponse GetApiVersion(NzbVortexSettings settings) + { + var requestBuilder = BuildRequest(settings).Resource("app/apilevel"); + + var response = ProcessRequest(requestBuilder, false, settings); + + return response; + } + + public List GetGroups(NzbVortexSettings settings) + { + var request = BuildRequest(settings).Resource("group"); + var response = ProcessRequest(request, true, settings); + + return response.Groups; + } + + public List GetQueue(int doneLimit, NzbVortexSettings settings) + { + var requestBuilder = BuildRequest(settings).Resource("nzb"); + + if (settings.Category.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("groupName", settings.Category); + } + + requestBuilder.AddQueryParam("limitDone", doneLimit.ToString()); + + var response = ProcessRequest(requestBuilder, true, settings); + + return response.Items; + } + + public List GetFiles(int id, NzbVortexSettings settings) + { + var requestBuilder = BuildRequest(settings).Resource(string.Format("file/{0}", id)); + + var response = ProcessRequest(requestBuilder, true, settings); + + return response.Files; + } + + private HttpRequestBuilder BuildRequest(NzbVortexSettings settings) + { + var baseUrl = HttpRequestBuilder.BuildBaseUrl(true, settings.Host, settings.Port, settings.UrlBase); + baseUrl = HttpUri.CombinePath(baseUrl, "api"); + var requestBuilder = new HttpRequestBuilder(baseUrl); + requestBuilder.LogResponseContent = true; + + return requestBuilder; + } + + private T ProcessRequest(HttpRequestBuilder requestBuilder, bool requiresAuthentication, NzbVortexSettings settings) + where T : NzbVortexResponseBase, new() + { + if (requiresAuthentication) + { + AuthenticateClient(requestBuilder, settings); + } + + HttpResponse response = null; + try + { + response = _httpClient.Execute(requestBuilder.Build()); + + var result = Json.Deserialize(response.Content); + + if (result.Result == NzbVortexResultType.NotLoggedIn) + { + _logger.Debug("Not logged in response received, reauthenticating and retrying"); + AuthenticateClient(requestBuilder, settings, true); + + response = _httpClient.Execute(requestBuilder.Build()); + + result = Json.Deserialize(response.Content); + + if (result.Result == NzbVortexResultType.NotLoggedIn) + { + throw new DownloadClientException("Unable to connect to remain authenticated to NzbVortex"); + } + } + + return result; + } + catch (JsonException ex) + { + throw new DownloadClientException("NzbVortex response could not be processed {0}: {1}", ex.Message, response.Content); + } + catch (HttpException ex) + { + throw new DownloadClientException("Unable to connect to NZBVortex, please check your settings", ex); + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to NZBVortex, certificate validation failed.", ex); + } + + throw new DownloadClientUnavailableException("Unable to connect to NZBVortex, please check your settings", ex); + } + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, NzbVortexSettings settings, bool reauthenticate = false) + { + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.ApiKey); + + var sessionId = _authSessionIdCache.Find(authKey); + + if (sessionId == null || reauthenticate) + { + _authSessionIdCache.Remove(authKey); + + var nonceRequest = BuildRequest(settings).Resource("auth/nonce").Build(); + var nonceResponse = _httpClient.Execute(nonceRequest); + + var nonce = Json.Deserialize(nonceResponse.Content).AuthNonce; + + var cnonce = Guid.NewGuid().ToString(); + + var hashString = string.Format("{0}:{1}:{2}", nonce, cnonce, settings.ApiKey); + var hash = Convert.ToBase64String(hashString.SHA256Hash().HexToByteArray()); + + var authRequest = BuildRequest(settings).Resource("auth/login") + .AddQueryParam("nonce", nonce) + .AddQueryParam("cnonce", cnonce) + .AddQueryParam("hash", hash) + .Build(); + var authResponse = _httpClient.Execute(authRequest); + var authResult = Json.Deserialize(authResponse.Content); + + if (authResult.LoginResult == NzbVortexLoginResultType.Failed) + { + throw new NzbVortexAuthenticationException("Authentication failed, check your API Key"); + } + + sessionId = authResult.SessionId; + + _authSessionIdCache.Set(authKey, sessionId); + } + + requestBuilder.AddQueryParam("sessionid", sessionId); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexQueueItem.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexQueueItem.cs new file mode 100644 index 000000000..9d009c3e1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexQueueItem.cs @@ -0,0 +1,23 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public class NzbVortexQueueItem + { + public int Id { get; set; } + public string UiTitle { get; set; } + public string DestinationPath { get; set; } + public string NzbFilename { get; set; } + public bool IsPaused { get; set; } + public NzbVortexStateType State { get; set; } + public string StatusText { get; set; } + public int TransferedSpeed { get; set; } + public double Progress { get; set; } + public long DownloadedSize { get; set; } + public long TotalDownloadSize { get; set; } + public long PostDate { get; set; } + public int TotalArticleCount { get; set; } + public int FailedArticleCount { get; set; } + public string GroupUUID { get; set; } + public string AddUUID { get; set; } + public string GroupName { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexResultType.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexResultType.cs new file mode 100644 index 000000000..0fa0a1d3a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexResultType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public enum NzbVortexResultType + { + Ok, + NotLoggedIn, + UnknownCommand + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs new file mode 100644 index 000000000..c6b03f0e7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs @@ -0,0 +1,61 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public class NzbVortexSettingsValidator : AbstractValidator + { + public NzbVortexSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + + RuleFor(c => c.ApiKey).NotEmpty() + .WithMessage("API Key is required"); + + RuleFor(c => c.Category).NotEmpty() + .WithMessage("A category is recommended") + .AsWarning(); + } + } + + public class NzbVortexSettings : IProviderConfig + { + private static readonly NzbVortexSettingsValidator Validator = new NzbVortexSettingsValidator(); + + public NzbVortexSettings() + { + Host = "localhost"; + Port = 4321; + Category = "Prowlarr"; + Priority = (int)NzbVortexPriority.Normal; + } + + [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 = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the NZBVortex url, e.g. http://[host]:[port]/[urlBase]/api")] + public string UrlBase { get; set; } + + [FieldDefinition(3, Label = "API Key", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)] + public string ApiKey { get; set; } + + [FieldDefinition(4, Label = "Group", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + public string Category { get; set; } + + [FieldDefinition(5, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "Priority to use when grabbing items")] + public int Priority { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexStateType.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexStateType.cs new file mode 100644 index 000000000..e409a6044 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexStateType.cs @@ -0,0 +1,31 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex +{ + public enum NzbVortexStateType + { + Waiting = 0, + Downloading = 1, + WaitingForSave = 2, + Saving = 3, + Saved = 4, + PasswordRequest = 5, + QuaedForProcessing = 6, + UserWaitForProcessing = 7, + Checking = 8, + Repairing = 9, + Joining = 10, + WaitForFurtherProcessing = 11, + Joining2 = 12, + WaitForUncompress = 13, + Uncompressing = 14, + WaitForCleanup = 15, + CleaningUp = 16, + CleanedUp = 17, + MovingToCompleted = 18, + MoveCompleted = 19, + Done = 20, + UncompressFailed = 21, + CheckFailedDataCorrupt = 22, + MoveFailed = 23, + BadlyEncoded = 24 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAddResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAddResponse.cs new file mode 100644 index 000000000..e41986ab1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAddResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexAddResponse : NzbVortexResponseBase + { + [JsonProperty(PropertyName = "add_uuid")] + public string Id { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexApiVersionResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexApiVersionResponse.cs new file mode 100644 index 000000000..7f2c34730 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexApiVersionResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexApiVersionResponse : NzbVortexResponseBase + { + public string ApiLevel { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAuthNonceResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAuthNonceResponse.cs new file mode 100644 index 000000000..32c8e4faa --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAuthNonceResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexAuthNonceResponse + { + public string AuthNonce { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAuthResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAuthResponse.cs new file mode 100644 index 000000000..5eefa7c74 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexAuthResponse.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using NzbDrone.Core.Download.Clients.NzbVortex.JsonConverters; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexAuthResponse : NzbVortexResponseBase + { + [JsonConverter(typeof(NzbVortexLoginResultTypeConverter))] + public NzbVortexLoginResultType LoginResult { get; set; } + + public string SessionId { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexFilesResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexFilesResponse.cs new file mode 100644 index 000000000..abe2f76cb --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexFilesResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexFilesResponse : NzbVortexResponseBase + { + public List Files { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexGroupResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexGroupResponse.cs new file mode 100644 index 000000000..9ae93264e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexGroupResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexGroupResponse : NzbVortexResponseBase + { + public List Groups { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexQueueResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexQueueResponse.cs new file mode 100644 index 000000000..2f5adb87f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexQueueResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexQueueResponse : NzbVortexResponseBase + { + [JsonProperty(PropertyName = "nzbs")] + public List Items { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexResponseBase.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexResponseBase.cs new file mode 100644 index 000000000..7ada482e1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexResponseBase.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using NzbDrone.Core.Download.Clients.NzbVortex.JsonConverters; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexResponseBase + { + [JsonConverter(typeof(NzbVortexResultTypeConverter))] + public NzbVortexResultType Result { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexRetryResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexRetryResponse.cs new file mode 100644 index 000000000..62038fe55 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexRetryResponse.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexRetryResponse + { + public bool Status { get; set; } + + [JsonProperty(PropertyName = "nzo_id")] + public string Id { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexVersionResponse.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexVersionResponse.cs new file mode 100644 index 000000000..0839e686d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/Responses/NzbVortexVersionResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses +{ + public class NzbVortexVersionResponse : NzbVortexResponseBase + { + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/ErrorModel.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/ErrorModel.cs new file mode 100644 index 000000000..5a917c636 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/ErrorModel.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class ErrorModel + { + public string Name { get; set; } + public int Code { get; set; } + public string Message { get; set; } + + public override string ToString() + { + return string.Format("Name: {0}, Code: {1}, Message: {2}", Name, Code, Message); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/JsonError.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/JsonError.cs new file mode 100644 index 000000000..6d76872d9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/JsonError.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class JsonError + { + public string Version { get; set; } + public ErrorModel Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs new file mode 100644 index 000000000..474f4954f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +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.Exceptions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class Nzbget : UsenetClientBase + { + private readonly INzbgetProxy _proxy; + private readonly string[] _successStatus = { "SUCCESS", "NONE" }; + private readonly string[] _deleteFailedStatus = { "HEALTH", "DUPE", "SCAN", "COPY", "BAD" }; + + public Nzbget(INzbgetProxy proxy, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IValidateNzbs nzbValidationService, + Logger logger) + : base(httpClient, configService, diskProvider, nzbValidationService, logger) + { + _proxy = proxy; + } + + protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContent) + { + var category = Settings.Category; + + var priority = Settings.Priority; + + var addpaused = Settings.AddPaused; + var response = _proxy.DownloadNzb(fileContent, filename, category, priority, addpaused, Settings); + + if (response == null) + { + throw new DownloadClientRejectedReleaseException(release, "NZBGet rejected the NZB for an unknown reason"); + } + + return response; + } + + protected override string AddFromLink(ReleaseInfo release) + { + var category = Settings.Category; + + var priority = Settings.Priority; + + var addpaused = Settings.AddPaused; + var response = _proxy.DownloadNzbByLink(release.DownloadUrl, category, priority, addpaused, Settings); + + if (response == null) + { + throw new DownloadClientRejectedReleaseException(release, "NZBGet rejected the NZB for an unknown reason"); + } + + return response; + } + + public override string Name => "NZBGet"; + + protected IEnumerable GetCategories(Dictionary config) + { + for (int i = 1; i < 100; i++) + { + var name = config.GetValueOrDefault("Category" + i + ".Name"); + + if (name == null) + { + yield break; + } + + var destDir = config.GetValueOrDefault("Category" + i + ".DestDir"); + + if (destDir.IsNullOrWhiteSpace()) + { + var mainDir = config.GetValueOrDefault("MainDir"); + destDir = config.GetValueOrDefault("DestDir", string.Empty).Replace("${MainDir}", mainDir); + + if (config.GetValueOrDefault("AppendCategoryDir", "yes") == "yes") + { + destDir = Path.Combine(destDir, name); + } + } + + yield return new NzbgetCategory + { + Name = name, + DestDir = destDir, + Unpack = config.GetValueOrDefault("Category" + i + ".Unpack") == "yes", + DefScript = config.GetValueOrDefault("Category" + i + ".DefScript"), + Aliases = config.GetValueOrDefault("Category" + i + ".Aliases"), + }; + } + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + failures.AddIfNotNull(TestCategory()); + failures.AddIfNotNull(TestSettings()); + } + + private ValidationFailure TestConnection() + { + try + { + var version = _proxy.GetVersion(Settings).Split('-')[0]; + + if (Version.Parse(version) < Version.Parse("12.0")) + { + return new ValidationFailure(string.Empty, "NZBGet version too low, need 12.0 or higher"); + } + } + catch (Exception ex) + { + if (ex.Message.ContainsIgnoreCase("Authentication failed")) + { + return new ValidationFailure("Username", "Authentication failed"); + } + + _logger.Error(ex, "Unable to connect to NZBGet"); + return new ValidationFailure("Host", "Unable to connect to NZBGet"); + } + + return null; + } + + private ValidationFailure TestCategory() + { + var config = _proxy.GetConfig(Settings); + var categories = GetCategories(config); + + if (!Settings.Category.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == Settings.Category)) + { + return new NzbDroneValidationFailure("Category", "Category does not exist") + { + InfoLink = _proxy.GetBaseUrl(Settings), + DetailedDescription = "The category you entered doesn't exist in NZBGet. Go to NZBGet to create it." + }; + } + + return null; + } + + private ValidationFailure TestSettings() + { + var config = _proxy.GetConfig(Settings); + + var keepHistory = config.GetValueOrDefault("KeepHistory", "7"); + int value; + if (!int.TryParse(keepHistory, NumberStyles.None, CultureInfo.InvariantCulture, out value) || value == 0) + { + return new NzbDroneValidationFailure(string.Empty, "NzbGet setting KeepHistory should be greater than 0") + { + InfoLink = _proxy.GetBaseUrl(Settings), + DetailedDescription = "NzbGet setting KeepHistory is set to 0. Which prevents Prowlarr from seeing completed downloads." + }; + } + else if (value > 25000) + { + return new NzbDroneValidationFailure(string.Empty, "NzbGet setting KeepHistory should be less than 25000") + { + InfoLink = _proxy.GetBaseUrl(Settings), + DetailedDescription = "NzbGet setting KeepHistory is set too high." + }; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetCategory.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetCategory.cs new file mode 100644 index 000000000..c1c07bb4d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetCategory.cs @@ -0,0 +1,11 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetCategory + { + public string Name { get; set; } + public string DestDir { get; set; } + public bool Unpack { get; set; } + public string DefScript { get; set; } + public string Aliases { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetConfigItem.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetConfigItem.cs new file mode 100644 index 000000000..0a3b2f4a1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetConfigItem.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetConfigItem + { + public string Name { get; set; } + public string Value { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetGlobalStatus.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetGlobalStatus.cs new file mode 100644 index 000000000..b39f2a4ac --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetGlobalStatus.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetGlobalStatus + { + public uint RemainingSizeLo { get; set; } + public uint RemainingSizeHi { get; set; } + public uint DownloadedSizeLo { get; set; } + public uint DownloadedSizeHi { get; set; } + public int DownloadRate { get; set; } + public int AverageDownloadRate { get; set; } + public int DownloadLimit { get; set; } + public bool DownloadPaused { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetHistoryItem.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetHistoryItem.cs new file mode 100644 index 000000000..dc0b8a9ba --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetHistoryItem.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetHistoryItem + { + public int Id { get; set; } + public string Name { get; set; } + public string Category { get; set; } + public uint FileSizeLo { get; set; } + public uint FileSizeHi { get; set; } + public string ParStatus { get; set; } + public string UnpackStatus { get; set; } + public string MoveStatus { get; set; } + public string ScriptStatus { get; set; } + public string DeleteStatus { get; set; } + public string MarkStatus { get; set; } + public string DestDir { get; set; } + public string FinalDir { get; set; } + public List Parameters { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetParameter.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetParameter.cs new file mode 100644 index 000000000..58cd8d910 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetParameter.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetParameter + { + public string Name { get; set; } + public object Value { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPostQueueItem.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPostQueueItem.cs new file mode 100644 index 000000000..c4f01a865 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPostQueueItem.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetPostQueueItem + { + public int NzbId { get; set; } + public string NzbName { get; set; } + public string Stage { get; set; } + public string ProgressLabel { get; set; } + public int FileProgress { get; set; } + public int StageProgress { get; set; } + public int TotalTimeSec { get; set; } + public int StageTimeSec { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPriority.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPriority.cs new file mode 100644 index 000000000..6b0144521 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetPriority.cs @@ -0,0 +1,12 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public enum NzbgetPriority + { + VeryLow = -100, + Low = -50, + Normal = 0, + High = 50, + VeryHigh = 100, + Force = 900 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs new file mode 100644 index 000000000..f63a941b6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public interface INzbgetProxy + { + string GetBaseUrl(NzbgetSettings settings, string relativePath = null); + string DownloadNzbByLink(string url, string category, int priority, bool addpaused, NzbgetSettings settings); + string DownloadNzb(byte[] nzbData, string title, string category, int priority, bool addpaused, NzbgetSettings settings); + NzbgetGlobalStatus GetGlobalStatus(NzbgetSettings settings); + List GetQueue(NzbgetSettings settings); + List GetHistory(NzbgetSettings settings); + string GetVersion(NzbgetSettings settings); + Dictionary GetConfig(NzbgetSettings settings); + void RemoveItem(string id, NzbgetSettings settings); + void RetryDownload(string id, NzbgetSettings settings); + } + + public class NzbgetProxy : INzbgetProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + private readonly ICached _versionCache; + + public NzbgetProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _versionCache = cacheManager.GetCache(GetType(), "versions"); + } + + public string GetBaseUrl(NzbgetSettings settings, string relativePath = null) + { + var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + baseUrl = HttpUri.CombinePath(baseUrl, relativePath); + + return baseUrl; + } + + private bool HasVersion(int minimumVersion, NzbgetSettings settings) + { + var versionString = _versionCache.Find(GetBaseUrl(settings)) ?? GetVersion(settings); + + var version = int.Parse(versionString.Split(new[] { '.', '-' })[0]); + + return version >= minimumVersion; + } + + public string DownloadNzbByLink(string url, string category, int priority, bool addpaused, NzbgetSettings settings) + { + var droneId = Guid.NewGuid().ToString().Replace("-", ""); + var response = ProcessRequest(settings, "append", "", url, category, priority, false, addpaused, string.Empty, 0, "all", new string[] { "drone", droneId }); + if (response <= 0) + { + return null; + } + + return droneId; + } + + public string DownloadNzb(byte[] nzbData, string title, string category, int priority, bool addpaused, NzbgetSettings settings) + { + if (HasVersion(16, settings)) + { + var droneId = Guid.NewGuid().ToString().Replace("-", ""); + var response = ProcessRequest(settings, "append", title, nzbData, category, priority, false, addpaused, string.Empty, 0, "all", new string[] { "drone", droneId }); + if (response <= 0) + { + return null; + } + + return droneId; + } + else if (HasVersion(13, settings)) + { + return DownloadNzbLegacy13(nzbData, title, category, priority, settings); + } + else + { + return DownloadNzbLegacy12(nzbData, title, category, priority, settings); + } + } + + private string DownloadNzbLegacy13(byte[] nzbData, string title, string category, int priority, NzbgetSettings settings) + { + var response = ProcessRequest(settings, "append", title, nzbData, category, priority, false, false, string.Empty, 0, "all"); + if (response <= 0) + { + return null; + } + + var queue = GetQueue(settings); + var item = queue.FirstOrDefault(q => q.NzbId == response); + + if (item == null) + { + return null; + } + + var droneId = Guid.NewGuid().ToString().Replace("-", ""); + var editResult = EditQueue("GroupSetParameter", 0, "drone=" + droneId, item.NzbId, settings); + if (editResult) + { + _logger.Debug("NZBGet download drone parameter set to: {0}", droneId); + } + + return droneId; + } + + private string DownloadNzbLegacy12(byte[] nzbData, string title, string category, int priority, NzbgetSettings settings) + { + var response = ProcessRequest(settings, "append", title, category, priority, false, nzbData); + if (!response) + { + return null; + } + + var queue = GetQueue(settings); + var item = queue.FirstOrDefault(q => q.NzbName == title.Substring(0, title.Length - 4)); + + if (item == null) + { + return null; + } + + var droneId = Guid.NewGuid().ToString().Replace("-", ""); + var editResult = EditQueue("GroupSetParameter", 0, "drone=" + droneId, item.LastId, settings); + + if (editResult) + { + _logger.Debug("NZBGet download drone parameter set to: {0}", droneId); + } + + return droneId; + } + + public NzbgetGlobalStatus GetGlobalStatus(NzbgetSettings settings) + { + return ProcessRequest(settings, "status"); + } + + public List GetQueue(NzbgetSettings settings) + { + return ProcessRequest>(settings, "listgroups"); + } + + public List GetHistory(NzbgetSettings settings) + { + return ProcessRequest>(settings, "history"); + } + + public string GetVersion(NzbgetSettings settings) + { + var response = ProcessRequest(settings, "version"); + + _versionCache.Set(GetBaseUrl(settings), response, TimeSpan.FromDays(1)); + + return response; + } + + public Dictionary GetConfig(NzbgetSettings settings) + { + return ProcessRequest>(settings, "config").ToDictionary(v => v.Name, v => v.Value); + } + + public void RemoveItem(string id, NzbgetSettings settings) + { + var queue = GetQueue(settings); + var history = GetHistory(settings); + + int nzbId; + NzbgetQueueItem queueItem; + NzbgetHistoryItem historyItem; + + if (id.Length < 10 && int.TryParse(id, out nzbId)) + { + // Download wasn't grabbed by Prowlarr, so the id is the NzbId reported by nzbget. + queueItem = queue.SingleOrDefault(h => h.NzbId == nzbId); + historyItem = history.SingleOrDefault(h => h.Id == nzbId); + } + else + { + queueItem = queue.SingleOrDefault(h => h.Parameters.Any(p => p.Name == "drone" && id == (p.Value as string))); + historyItem = history.SingleOrDefault(h => h.Parameters.Any(p => p.Name == "drone" && id == (p.Value as string))); + } + + if (queueItem != null) + { + if (!EditQueue("GroupFinalDelete", 0, "", queueItem.NzbId, settings)) + { + _logger.Warn("Failed to remove item from NZBGet, {0} [{1}]", queueItem.NzbName, queueItem.NzbId); + } + } + else if (historyItem != null) + { + if (!EditQueue("HistoryDelete", 0, "", historyItem.Id, settings)) + { + _logger.Warn("Failed to remove item from NZBGet history, {0} [{1}]", historyItem.Name, historyItem.Id); + } + } + else + { + _logger.Warn("Unable to remove item from NZBGet, Unknown ID: {0}", id); + return; + } + } + + public void RetryDownload(string id, NzbgetSettings settings) + { + var history = GetHistory(settings); + var item = history.SingleOrDefault(h => h.Parameters.SingleOrDefault(p => p.Name == "drone" && id == (p.Value as string)) != null); + + if (item == null) + { + _logger.Warn("Unable to return item to queue, Unknown ID: {0}", id); + return; + } + + if (!EditQueue("HistoryRedownload", 0, "", item.Id, settings)) + { + _logger.Warn("Failed to return item to queue from history, {0} [{1}]", item.Name, item.Id); + } + } + + private bool EditQueue(string command, int offset, string editText, int id, NzbgetSettings settings) + { + return ProcessRequest(settings, "editqueue", command, offset, editText, id); + } + + private T ProcessRequest(NzbgetSettings settings, string method, params object[] parameters) + { + var baseUrl = GetBaseUrl(settings, "jsonrpc"); + + var requestBuilder = new JsonRpcRequestBuilder(baseUrl, method, parameters); + requestBuilder.LogResponseContent = true; + requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + + var httpRequest = requestBuilder.Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(httpRequest); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new DownloadClientAuthenticationException("Authentication failed for NzbGet, please check your settings", ex); + } + + throw new DownloadClientException("Unable to connect to NZBGet. " + ex.Message, ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Unable to connect to NzbGet. " + ex.Message, ex); + } + + var result = Json.Deserialize>(response.Content); + + if (result.Error != null) + { + throw new DownloadClientException("Error response received from NZBGet: {0}", result.Error.ToString()); + } + + return result.Result; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetQueueItem.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetQueueItem.cs new file mode 100644 index 000000000..f70c7c70b --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetQueueItem.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetQueueItem + { + public int NzbId { get; set; } + public int FirstId { get; set; } + public int LastId { get; set; } + public string NzbName { get; set; } + public string Category { get; set; } + public uint FileSizeLo { get; set; } + public uint FileSizeHi { get; set; } + public uint RemainingSizeLo { get; set; } + public uint RemainingSizeHi { get; set; } + public uint PausedSizeLo { get; set; } + public uint PausedSizeHi { get; set; } + public int MinPriority { get; set; } + public int MaxPriority { get; set; } + public int ActiveDownloads { get; set; } + public List Parameters { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetResponse.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetResponse.cs new file mode 100644 index 000000000..c6132fbae --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetResponse.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetResponse + { + public string Version { get; set; } + + public T Result { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs new file mode 100644 index 000000000..159647ae6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs @@ -0,0 +1,70 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Nzbget +{ + public class NzbgetSettingsValidator : AbstractValidator + { + public NzbgetSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + + RuleFor(c => c.Username).NotEmpty().When(c => !string.IsNullOrWhiteSpace(c.Password)); + RuleFor(c => c.Password).NotEmpty().When(c => !string.IsNullOrWhiteSpace(c.Username)); + + RuleFor(c => c.Category).NotEmpty().WithMessage("A category is recommended").AsWarning(); + } + } + + public class NzbgetSettings : IProviderConfig + { + private static readonly NzbgetSettingsValidator Validator = new NzbgetSettingsValidator(); + + public NzbgetSettings() + { + Host = "localhost"; + Port = 6789; + Category = "Prowlarr"; + Username = "nzbget"; + Password = "tegbzn6789"; + Priority = (int)NzbgetPriority.Normal; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Sabnzbd")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the nzbget url, e.g. http://[host]:[port]/[urlBase]/jsonrpc")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + public string Category { get; set; } + + [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority for items added from Prowlarr")] + public int Priority { get; set; } + + [FieldDefinition(8, Label = "Add Paused", Type = FieldType.Checkbox, HelpText = "This option requires at least NZBGet version 16.0")] + public bool AddPaused { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs new file mode 100644 index 000000000..72fbf4fa4 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download.Clients.Pneumatic +{ + public class Pneumatic : DownloadClientBase + { + private readonly IHttpClient _httpClient; + + public Pneumatic(IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(configService, diskProvider, logger) + { + _httpClient = httpClient; + } + + public override string Name => "Pneumatic"; + + public override DownloadProtocol Protocol => DownloadProtocol.Usenet; + + public override string Download(ReleaseInfo release, bool redirect) + { + var url = release.DownloadUrl; + var title = release.Title; + + title = StringUtil.CleanFileName(title); + + //Save to the Pneumatic directory (The user will need to ensure its accessible by XBMC) + var nzbFile = Path.Combine(Settings.NzbFolder, title + ".nzb"); + + _logger.Debug("Downloading NZB from: {0} to: {1}", url, nzbFile); + _httpClient.DownloadFile(url, nzbFile); + + _logger.Debug("NZB Download succeeded, saved to: {0}", nzbFile); + + var strmFile = WriteStrmFile(title, nzbFile); + + return GetDownloadClientId(strmFile); + } + + public bool IsConfigured => !string.IsNullOrWhiteSpace(Settings.NzbFolder); + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestFolder(Settings.NzbFolder, "NzbFolder")); + failures.AddIfNotNull(TestFolder(Settings.StrmFolder, "StrmFolder")); + } + + private string WriteStrmFile(string title, string nzbFile) + { + if (Settings.StrmFolder.IsNullOrWhiteSpace()) + { + throw new DownloadClientException("Strm Folder needs to be set for Pneumatic Downloader"); + } + + var contents = string.Format("plugin://plugin.program.pneumatic/?mode=strm&type=add_file&nzb={0}&nzbname={1}", nzbFile, title); + var filename = Path.Combine(Settings.StrmFolder, title + ".strm"); + + _diskProvider.WriteAllText(filename, contents); + + return filename; + } + + private string GetDownloadClientId(string filename) + { + return Definition.Name + "_" + Path.GetFileName(filename) + "_" + _diskProvider.FileGetLastWrite(filename).Ticks; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Pneumatic/PneumaticSettings.cs b/src/NzbDrone.Core/Download/Clients/Pneumatic/PneumaticSettings.cs new file mode 100644 index 000000000..741021a3f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/PneumaticSettings.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.Download.Clients.Pneumatic +{ + public class PneumaticSettingsValidator : AbstractValidator + { + public PneumaticSettingsValidator() + { + RuleFor(c => c.NzbFolder).IsValidPath(); + RuleFor(c => c.StrmFolder).IsValidPath(); + } + } + + public class PneumaticSettings : IProviderConfig + { + private static readonly PneumaticSettingsValidator Validator = new PneumaticSettingsValidator(); + + [FieldDefinition(0, Label = "Nzb Folder", Type = FieldType.Path, HelpText = "This folder will need to be reachable from XBMC")] + public string NzbFolder { get; set; } + + [FieldDefinition(1, Label = "Strm Folder", Type = FieldType.Path, HelpText = ".strm files in this folder will be import by drone")] + public string StrmFolder { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs new file mode 100644 index 000000000..ca77506e6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -0,0 +1,458 @@ +using System; +using System.Collections.Generic; +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.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public class QBittorrent : TorrentClientBase + { + private readonly IQBittorrentProxySelector _proxySelector; + private readonly ICached _seedingTimeCache; + + private class SeedingTimeCacheEntry + { + public DateTime LastFetched { get; set; } + public long SeedingTime { get; set; } + } + + public QBittorrent(IQBittorrentProxySelector proxySelector, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + ICacheManager cacheManager, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + _proxySelector = proxySelector; + + _seedingTimeCache = cacheManager.GetCache(GetType(), "seedingTime"); + } + + private IQBittorrentProxy Proxy => _proxySelector.GetProxy(Settings); + private Version ProxyApiVersion => _proxySelector.GetApiVersion(Settings); + + protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + { + if (!Proxy.GetConfig(Settings).DhtEnabled && !magnetLink.Contains("&tr=")) + { + throw new NotSupportedException("Magnet Links without trackers not supported if DHT is disabled"); + } + + //var setShareLimits = release.SeedConfiguration != null && (release.SeedConfiguration.Ratio.HasValue || release.SeedConfiguration.SeedTime.HasValue); + //var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1); + var itemToTop = Settings.Priority == (int)QBittorrentPriority.First; + var forceStart = (QBittorrentState)Settings.InitialState == QBittorrentState.ForceStart; + + Proxy.AddTorrentFromUrl(magnetLink, null, Settings); + + if (itemToTop || forceStart) + { + if (!WaitForTorrent(hash)) + { + return hash; + } + + //if (!addHasSetShareLimits && setShareLimits) + //{ + // Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), release.SeedConfiguration, Settings); + //} + if (itemToTop) + { + try + { + Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set the torrent priority for {0}.", hash); + } + } + + if (forceStart) + { + try + { + Proxy.SetForceStart(hash.ToLower(), true, Settings); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set ForceStart for {0}.", hash); + } + } + } + + return hash; + } + + protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + { + //var setShareLimits = release.SeedConfiguration != null && (release.SeedConfiguration.Ratio.HasValue || release.SeedConfiguration.SeedTime.HasValue); + //var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1); + var itemToTop = Settings.Priority == (int)QBittorrentPriority.First; + var forceStart = (QBittorrentState)Settings.InitialState == QBittorrentState.ForceStart; + + Proxy.AddTorrentFromFile(filename, fileContent, null, Settings); + + if (itemToTop || forceStart) + { + if (!WaitForTorrent(hash)) + { + return hash; + } + + //if (!addHasSetShareLimits && setShareLimits) + //{ + // Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), release.SeedConfiguration, Settings); + //} + if (itemToTop) + { + try + { + Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set the torrent priority for {0}.", hash); + } + } + + if (forceStart) + { + try + { + Proxy.SetForceStart(hash.ToLower(), true, Settings); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to set ForceStart for {0}.", hash); + } + } + } + + return hash; + } + + protected bool WaitForTorrent(string hash) + { + var count = 5; + + while (count != 0) + { + try + { + Proxy.GetTorrentProperties(hash.ToLower(), Settings); + return true; + } + catch + { + } + + _logger.Trace("Torrent '{0}' not yet visible in qbit, waiting 100ms.", hash); + System.Threading.Thread.Sleep(100); + count--; + } + + _logger.Warn("Failed to load torrent '{0}' within 500 ms, skipping additional parameters.", hash); + return false; + } + + public override string Name => "qBittorrent"; + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.HasErrors()) + { + return; + } + + failures.AddIfNotNull(TestCategory()); + failures.AddIfNotNull(TestPrioritySupport()); + failures.AddIfNotNull(TestGetTorrents()); + } + + private ValidationFailure TestConnection() + { + try + { + var version = _proxySelector.GetProxy(Settings, true).GetApiVersion(Settings); + if (version < Version.Parse("1.5")) + { + // API version 5 introduced the "save_path" property in /query/torrents + return new NzbDroneValidationFailure("Host", "Unsupported client version") + { + DetailedDescription = "Please upgrade to qBittorrent version 3.2.4 or higher." + }; + } + else if (version < Version.Parse("1.6")) + { + // API version 6 introduced support for labels + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + return new NzbDroneValidationFailure("Category", "Category is not supported") + { + DetailedDescription = "Labels are not supported until qBittorrent version 3.3.0. Please upgrade or try again with an empty Category." + }; + } + } + else if (Settings.Category.IsNullOrWhiteSpace()) + { + // warn if labels are supported, but category is not provided + return new NzbDroneValidationFailure("Category", "Category is recommended") + { + IsWarning = true, + DetailedDescription = "Prowlarr will not attempt to import completed downloads without a category." + }; + } + + // Complain if qBittorrent is configured to remove torrents on max ratio + var config = Proxy.GetConfig(Settings); + if ((config.MaxRatioEnabled || config.MaxSeedingTimeEnabled) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles)) + { + return new NzbDroneValidationFailure(string.Empty, "qBittorrent is configured to remove torrents when they reach their Share Ratio Limit") + { + DetailedDescription = "Prowlarr will be unable to perform Completed Download Handling as configured. You can fix this in qBittorrent ('Tools -> Options...' in the menu) by changing 'Options -> BitTorrent -> Share Ratio Limiting' from 'Remove them' to 'Pause them'." + }; + } + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure("Username", "Authentication failure") + { + DetailedDescription = "Please verify your username and password." + }; + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to connect to qBittorrent"); + 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, "Unable to test qBittorrent"); + + return new NzbDroneValidationFailure("Host", "Unable to connect to qBittorrent") + { + DetailedDescription = ex.Message + }; + } + + return null; + } + + private ValidationFailure TestCategory() + { + if (Settings.Category.IsNullOrWhiteSpace()) + { + return null; + } + + // api v1 doesn't need to check/add categories as it's done on set + var version = _proxySelector.GetProxy(Settings, true).GetApiVersion(Settings); + if (version < Version.Parse("2.0")) + { + return null; + } + + Dictionary labels = Proxy.GetLabels(Settings); + + if (Settings.Category.IsNotNullOrWhiteSpace() && !labels.ContainsKey(Settings.Category)) + { + Proxy.AddLabel(Settings.Category, Settings); + labels = Proxy.GetLabels(Settings); + + if (!labels.ContainsKey(Settings.Category)) + { + return new NzbDroneValidationFailure("Category", "Configuration of label failed") + { + DetailedDescription = "Prowlarr was unable to add the label to qBittorrent." + }; + } + } + + return null; + } + + private ValidationFailure TestPrioritySupport() + { + var recentPriorityDefault = Settings.Priority == (int)QBittorrentPriority.Last; + + if (recentPriorityDefault) + { + return null; + } + + try + { + var config = Proxy.GetConfig(Settings); + + if (!config.QueueingEnabled) + { + if (!recentPriorityDefault) + { + return new NzbDroneValidationFailure(nameof(Settings.Priority), "Queueing not enabled") { DetailedDescription = "Torrent Queueing is not enabled in your qBittorrent settings. Enable it in qBittorrent or select 'Last' as priority." }; + } + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to test qBittorrent"); + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + Proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to get torrents"); + return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + + protected TimeSpan? GetRemainingTime(QBittorrentTorrent torrent) + { + if (torrent.Eta < 0 || torrent.Eta > 365 * 24 * 3600) + { + return null; + } + + // qBittorrent sends eta=8640000 if unknown such as queued + if (torrent.Eta == 8640000) + { + return null; + } + + return TimeSpan.FromSeconds((int)torrent.Eta); + } + + protected bool HasReachedSeedLimit(QBittorrentTorrent torrent, QBittorrentPreferences config) + { + if (torrent.RatioLimit >= 0) + { + if (torrent.Ratio >= torrent.RatioLimit) + { + return true; + } + } + else if (torrent.RatioLimit == -2 && config.MaxRatioEnabled) + { + if (torrent.Ratio >= config.MaxRatio) + { + return true; + } + } + + if (HasReachedSeedingTimeLimit(torrent, config)) + { + return true; + } + + return false; + } + + protected bool HasReachedSeedingTimeLimit(QBittorrentTorrent torrent, QBittorrentPreferences config) + { + long seedingTimeLimit; + + if (torrent.SeedingTimeLimit >= 0) + { + seedingTimeLimit = torrent.SeedingTimeLimit; + } + else if (torrent.SeedingTimeLimit == -2 && config.MaxSeedingTimeEnabled) + { + seedingTimeLimit = config.MaxSeedingTime; + } + else + { + return false; + } + + if (torrent.SeedingTime.HasValue) + { + // SeedingTime can't be available here, but use it if the api starts to provide it. + return torrent.SeedingTime.Value >= seedingTimeLimit; + } + + var cacheKey = Settings.Host + Settings.Port + torrent.Hash; + var cacheSeedingTime = _seedingTimeCache.Find(cacheKey); + + if (cacheSeedingTime != null) + { + var togo = seedingTimeLimit - cacheSeedingTime.SeedingTime; + var elapsed = (DateTime.UtcNow - cacheSeedingTime.LastFetched).TotalSeconds; + + if (togo <= 0) + { + // Already reached the limit, keep the cache alive + _seedingTimeCache.Set(cacheKey, cacheSeedingTime, TimeSpan.FromMinutes(5)); + return true; + } + else if (togo > elapsed) + { + // SeedingTime cannot have reached the required value since the last check, preserve the cache + _seedingTimeCache.Set(cacheKey, cacheSeedingTime, TimeSpan.FromMinutes(5)); + return false; + } + } + + FetchTorrentDetails(torrent); + + cacheSeedingTime = new SeedingTimeCacheEntry + { + LastFetched = DateTime.UtcNow, + SeedingTime = torrent.SeedingTime.Value + }; + + _seedingTimeCache.Set(cacheKey, cacheSeedingTime, TimeSpan.FromMinutes(5)); + + if (cacheSeedingTime.SeedingTime >= seedingTimeLimit) + { + // Reached the limit, keep the cache alive + return true; + } + + return false; + } + + protected void FetchTorrentDetails(QBittorrentTorrent torrent) + { + var torrentProperties = Proxy.GetTorrentProperties(torrent.Hash, Settings); + + torrent.SeedingTime = torrentProperties.SeedingTime; + } + + protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs new file mode 100644 index 000000000..224a079e9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentLabel.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public class QBittorrentLabel + { + public string Name { get; set; } + public string SavePath { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs new file mode 100644 index 000000000..e2979bd3a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public enum QBittorrentMaxRatioAction + { + Pause = 0, + Remove = 1, + EnableSuperSeeding = 2, + DeleteFiles = 3 + } + + // qbittorrent settings from the list returned by /query/preferences + public class QBittorrentPreferences + { + [JsonProperty(PropertyName = "save_path")] + public string SavePath { get; set; } // Default save path for torrents, separated by slashes + + [JsonProperty(PropertyName = "max_ratio_enabled")] + public bool MaxRatioEnabled { get; set; } // True if share ratio limit is enabled + + [JsonProperty(PropertyName = "max_ratio")] + public float MaxRatio { get; set; } // Get the global share ratio limit + + [JsonProperty(PropertyName = "max_seeding_time_enabled")] + public bool MaxSeedingTimeEnabled { get; set; } // True if share time limit is enabled + + [JsonProperty(PropertyName = "max_seeding_time")] + public long MaxSeedingTime { get; set; } // Get the global share time limit in minutes + + [JsonProperty(PropertyName = "max_ratio_act")] + public QBittorrentMaxRatioAction MaxRatioAction { get; set; } // Action performed when a torrent reaches the maximum share ratio. + + [JsonProperty(PropertyName = "queueing_enabled")] + public bool QueueingEnabled { get; set; } = true; + + [JsonProperty(PropertyName = "dht")] + public bool DhtEnabled { get; set; } // DHT enabled (needed for more peers and magnet downloads) + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPriority.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPriority.cs new file mode 100644 index 000000000..7374fc312 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public enum QBittorrentPriority + { + Last = 0, + First = 1 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs new file mode 100644 index 000000000..158db804e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; + +using NLog; +using NzbDrone.Common.Cache; + +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public interface IQBittorrentProxy + { + bool IsApiSupported(QBittorrentSettings settings); + Version GetApiVersion(QBittorrentSettings settings); + string GetVersion(QBittorrentSettings settings); + QBittorrentPreferences GetConfig(QBittorrentSettings settings); + List GetTorrents(QBittorrentSettings settings); + QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings); + List GetTorrentFiles(string hash, QBittorrentSettings settings); + + void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings); + void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings); + + void RemoveTorrent(string hash, bool removeData, QBittorrentSettings settings); + void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); + void AddLabel(string label, QBittorrentSettings settings); + Dictionary GetLabels(QBittorrentSettings settings); + void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings); + void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); + void PauseTorrent(string hash, QBittorrentSettings settings); + void ResumeTorrent(string hash, QBittorrentSettings settings); + void SetForceStart(string hash, bool enabled, QBittorrentSettings settings); + } + + public interface IQBittorrentProxySelector + { + IQBittorrentProxy GetProxy(QBittorrentSettings settings, bool force = false); + Version GetApiVersion(QBittorrentSettings settings, bool force = false); + } + + public class QBittorrentProxySelector : IQBittorrentProxySelector + { + private readonly IHttpClient _httpClient; + private readonly ICached> _proxyCache; + private readonly Logger _logger; + + private readonly IQBittorrentProxy _proxyV1; + private readonly IQBittorrentProxy _proxyV2; + + public QBittorrentProxySelector(QBittorrentProxyV1 proxyV1, + QBittorrentProxyV2 proxyV2, + IHttpClient httpClient, + ICacheManager cacheManager, + Logger logger) + { + _httpClient = httpClient; + _proxyCache = cacheManager.GetCache>(GetType()); + _logger = logger; + + _proxyV1 = proxyV1; + _proxyV2 = proxyV2; + } + + public IQBittorrentProxy GetProxy(QBittorrentSettings settings, bool force) + { + return GetProxyCache(settings, force).Item1; + } + + public Version GetApiVersion(QBittorrentSettings settings, bool force) + { + return GetProxyCache(settings, force).Item2; + } + + private Tuple GetProxyCache(QBittorrentSettings settings, bool force) + { + var proxyKey = $"{settings.Host}_{settings.Port}"; + + if (force) + { + _proxyCache.Remove(proxyKey); + } + + return _proxyCache.Get(proxyKey, () => FetchProxy(settings), TimeSpan.FromMinutes(10.0)); + } + + private Tuple FetchProxy(QBittorrentSettings settings) + { + if (_proxyV2.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v2"); + return Tuple.Create(_proxyV2, _proxyV2.GetApiVersion(settings)); + } + + if (_proxyV1.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v1"); + return Tuple.Create(_proxyV1, _proxyV1.GetApiVersion(settings)); + } + + throw new DownloadClientException("Unable to determine qBittorrent API version"); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs new file mode 100644 index 000000000..f955cb243 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -0,0 +1,387 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // API https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-Documentation + public class QBittorrentProxyV1 : IQBittorrentProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly ICached> _authCookieCache; + + public QBittorrentProxyV1(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public bool IsApiSupported(QBittorrentSettings settings) + { + // We can do the api test without having to authenticate since v4.1 will return 404 on the request. + var request = BuildRequest(settings).Resource("/version/api"); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + // Version request does not require authentication and will return 404 if it doesn't exist. + var request = BuildRequest(settings).Resource("/version/api"); + var response = Version.Parse("1." + ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + // Version request does not require authentication. + var request = BuildRequest(settings).Resource("/version/qbittorrent"); + var response = ProcessRequest(request, settings).TrimStart('v'); + + return response; + } + + public QBittorrentPreferences GetConfig(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/query/preferences"); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrents(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/query/torrents"); + if (settings.Category.IsNotNullOrWhiteSpace()) + { + request.AddQueryParam("label", settings.Category); + request.AddQueryParam("category", settings.Category); + } + + var response = ProcessRequest>(request, settings); + + return response; + } + + public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource($"/query/propertiesGeneral/{hash}"); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrentFiles(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource($"/query/propertiesFiles/{hash}"); + var response = ProcessRequest>(request, settings); + + return response; + } + + public void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/download") + .Post() + .AddFormParameter("urls", torrentUrl); + + if (settings.Category.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.Category); + } + + // Note: ForceStart is handled by separate api call + if ((QBittorrentState)settings.InitialState == QBittorrentState.Start) + { + request.AddFormParameter("paused", false); + } + else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", true); + } + + var result = ProcessRequest(request, settings); + + // Note: Older qbit versions returned nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent by url"); + } + } + + public void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/upload") + .Post() + .AddFormUpload("torrents", fileName, fileContent); + + if (settings.Category.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.Category); + } + + // Note: ForceStart is handled by separate api call + if ((QBittorrentState)settings.InitialState == QBittorrentState.Start) + { + request.AddFormParameter("paused", false); + } + else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", true); + } + + var result = ProcessRequest(request, settings); + + // Note: Current qbit versions return nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent"); + } + } + + public void RemoveTorrent(string hash, bool removeData, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource(removeData ? "/command/deletePerm" : "/command/delete") + .Post() + .AddFormParameter("hashes", hash); + + ProcessRequest(request, settings); + } + + public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) + { + var setCategoryRequest = BuildRequest(settings).Resource("/command/setCategory") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("category", label); + try + { + ProcessRequest(setCategoryRequest, settings); + } + catch (DownloadClientException ex) + { + // if setCategory fails due to method not being found, then try older setLabel command for qBittorrent < v.3.3.5 + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound) + { + var setLabelRequest = BuildRequest(settings).Resource("/command/setLabel") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("label", label); + + ProcessRequest(setLabelRequest, settings); + } + } + } + + public void AddLabel(string label, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/addCategory") + .Post() + .AddFormParameter("category", label); + ProcessRequest(request, settings); + } + + public Dictionary GetLabels(QBittorrentSettings settings) + { + throw new NotSupportedException("qBittorrent api v1 does not support getting all torrent categories"); + } + + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + // Not supported on api v1 + } + + public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/topPrio") + .Post() + .AddFormParameter("hashes", hash); + try + { + ProcessRequest(request, settings); + } + catch (DownloadClientException ex) + { + // qBittorrent rejects all Prio commands with 403: Forbidden if Options -> BitTorrent -> Torrent Queueing is not enabled + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Forbidden) + { + return; + } + + throw; + } + } + + public void PauseTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/pause") + .Post() + .AddFormParameter("hash", hash); + ProcessRequest(request, settings); + } + + public void ResumeTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/resume") + .Post() + .AddFormParameter("hash", hash); + ProcessRequest(request, settings); + } + + public void SetForceStart(string hash, bool enabled, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/setForceStart") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("value", enabled ? "true" : "false"); + ProcessRequest(request, settings); + } + + private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; + return requestBuilder; + } + + private TResult ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + where TResult : new() + { + var responseContent = ProcessRequest(requestBuilder, settings); + + return Json.Deserialize(responseContent); + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + { + AuthenticateClient(requestBuilder, settings); + + var request = requestBuilder.Build(); + request.LogResponseContent = true; + + HttpResponse response; + try + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Debug("Authentication required, logging in."); + + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Build(); + + response = _httpClient.Execute(request); + } + else + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + return response.Content; + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) + { + if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) + { + return; + } + + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var cookies = _authCookieCache.Find(authKey); + + if (cookies == null || reauthenticate) + { + _authCookieCache.Remove(authKey); + + var authLoginRequest = BuildRequest(settings).Resource("/login") + .Post() + .AddFormParameter("username", settings.Username ?? string.Empty) + .AddFormParameter("password", settings.Password ?? string.Empty) + .Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(authLoginRequest); + } + catch (HttpException ex) + { + _logger.Debug("qbitTorrent authentication failed."); + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); + } + + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + // returns "Fails." on bad login + if (response.Content != "Ok.") + { + _logger.Debug("qbitTorrent authentication failed."); + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); + } + + _logger.Debug("qBittorrent authentication succeeded."); + + cookies = response.GetCookies(); + + _authCookieCache.Set(authKey, cookies); + } + + requestBuilder.SetCookies(cookies); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs new file mode 100644 index 000000000..bb07e86ef --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -0,0 +1,433 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // API https://github.com/qbittorrent/qBittorrent/wiki/Web-API-Documentation + public class QBittorrentProxyV2 : IQBittorrentProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly ICached> _authCookieCache; + + public QBittorrentProxyV2(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public bool IsApiSupported(QBittorrentSettings settings) + { + // We can do the api test without having to authenticate since v3.2.0-v4.0.4 will return 404 on the request. + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to qBittorrent, certificate validation failed.", ex); + } + + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + var response = Version.Parse(ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/version"); + var response = ProcessRequest(request, settings).TrimStart('v'); + + // eg "4.2alpha" + return response; + } + + public QBittorrentPreferences GetConfig(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/preferences"); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrents(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/info"); + if (settings.Category.IsNotNullOrWhiteSpace()) + { + request.AddQueryParam("category", settings.Category); + } + + var response = ProcessRequest>(request, settings); + + return response; + } + + public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/properties") + .AddQueryParam("hash", hash); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrentFiles(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/files") + .AddQueryParam("hash", hash); + var response = ProcessRequest>(request, settings); + + return response; + } + + public void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormParameter("urls", torrentUrl); + if (settings.Category.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.Category); + } + + // Note: ForceStart is handled by separate api call + if ((QBittorrentState)settings.InitialState == QBittorrentState.Start) + { + request.AddFormParameter("paused", false); + } + else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", true); + } + + if (seedConfiguration != null) + { + AddTorrentSeedingFormParameters(request, seedConfiguration, settings); + } + + var result = ProcessRequest(request, settings); + + // Note: Older qbit versions returned nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent by url"); + } + } + + public void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormUpload("torrents", fileName, fileContent); + + if (settings.Category.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.Category); + } + + // Note: ForceStart is handled by separate api call + if ((QBittorrentState)settings.InitialState == QBittorrentState.Start) + { + request.AddFormParameter("paused", false); + } + else if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", true); + } + + if (seedConfiguration != null) + { + AddTorrentSeedingFormParameters(request, seedConfiguration, settings); + } + + var result = ProcessRequest(request, settings); + + // Note: Current qbit versions return nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent"); + } + } + + public void RemoveTorrent(string hash, bool removeData, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/delete") + .Post() + .AddFormParameter("hashes", hash); + + if (removeData) + { + request.AddFormParameter("deleteFiles", "true"); + } + + ProcessRequest(request, settings); + } + + public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/setCategory") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("category", label); + ProcessRequest(request, settings); + } + + public void AddLabel(string label, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/createCategory") + .Post() + .AddFormParameter("category", label); + ProcessRequest(request, settings); + } + + public Dictionary GetLabels(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/categories"); + return Json.Deserialize>(ProcessRequest(request, settings)); + } + + private void AddTorrentSeedingFormParameters(HttpRequestBuilder request, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + var ratioLimit = seedConfiguration.Ratio.HasValue ? seedConfiguration.Ratio : -2; + var seedingTimeLimit = seedConfiguration.SeedTime.HasValue ? (long)seedConfiguration.SeedTime.Value.TotalMinutes : -2; + + if (ratioLimit != -2) + { + request.AddFormParameter("ratioLimit", ratioLimit); + } + + if (seedingTimeLimit != -2) + { + request.AddFormParameter("seedingTimeLimit", seedingTimeLimit); + } + } + + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/setShareLimits") + .Post() + .AddFormParameter("hashes", hash); + + AddTorrentSeedingFormParameters(request, seedConfiguration, settings); + + try + { + ProcessRequest(request, settings); + } + catch (DownloadClientException ex) + { + // setShareLimits was added in api v2.0.1 so catch it case of the unlikely event that someone has api v2.0 + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound) + { + return; + } + + throw; + } + } + + public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/topPrio") + .Post() + .AddFormParameter("hashes", hash); + + try + { + ProcessRequest(request, settings); + } + catch (DownloadClientException ex) + { + // qBittorrent rejects all Prio commands with 409: Conflict if Options -> BitTorrent -> Torrent Queueing is not enabled + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Conflict) + { + return; + } + + throw; + } + } + + public void PauseTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/pause") + .Post() + .AddFormParameter("hashes", hash); + ProcessRequest(request, settings); + } + + public void ResumeTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/resume") + .Post() + .AddFormParameter("hashes", hash); + ProcessRequest(request, settings); + } + + public void SetForceStart(string hash, bool enabled, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/setForceStart") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("value", enabled ? "true" : "false"); + ProcessRequest(request, settings); + } + + private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; + return requestBuilder; + } + + private TResult ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + where TResult : new() + { + var responseContent = ProcessRequest(requestBuilder, settings); + + return Json.Deserialize(responseContent); + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + { + AuthenticateClient(requestBuilder, settings); + + var request = requestBuilder.Build(); + request.LogResponseContent = true; + + HttpResponse response; + try + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Debug("Authentication required, logging in."); + + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Build(); + + response = _httpClient.Execute(request); + } + else + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + return response.Content; + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) + { + if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) + { + if (reauthenticate) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); + } + + return; + } + + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var cookies = _authCookieCache.Find(authKey); + + if (cookies == null || reauthenticate) + { + _authCookieCache.Remove(authKey); + + var authLoginRequest = BuildRequest(settings).Resource("/api/v2/auth/login") + .Post() + .AddFormParameter("username", settings.Username ?? string.Empty) + .AddFormParameter("password", settings.Password ?? string.Empty) + .Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(authLoginRequest); + } + catch (HttpException ex) + { + _logger.Debug("qbitTorrent authentication failed."); + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); + } + + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + // returns "Fails." on bad login + if (response.Content != "Ok.") + { + _logger.Debug("qbitTorrent authentication failed."); + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); + } + + _logger.Debug("qBittorrent authentication succeeded."); + + cookies = response.GetCookies(); + + _authCookieCache.Set(authKey, cookies); + } + + requestBuilder.SetCookies(cookies); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs new file mode 100644 index 000000000..a18d14c34 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs @@ -0,0 +1,64 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public class QBittorrentSettingsValidator : AbstractValidator + { + public QBittorrentSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + + RuleFor(c => c.Category).Matches(@"^([^\\\/](\/?[^\\\/])*)?$").WithMessage(@"Can not contain '\', '//', or start/end with '/'"); + } + } + + public class QBittorrentSettings : IProviderConfig + { + private static readonly QBittorrentSettingsValidator Validator = new QBittorrentSettingsValidator(); + + public QBittorrentSettings() + { + Host = "localhost"; + Port = 8080; + Category = "prowlarr"; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use a secure connection. See Options -> Web UI -> 'Use HTTPS instead of HTTP' in qBittorrent.")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the qBittorrent url, e.g. http://[host]:[port]/[urlBase]/api")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + public string Category { get; set; } + + [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing items")] + public int Priority { get; set; } + + [FieldDefinition(8, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(QBittorrentState), HelpText = "Initial state for torrents added to qBittorrent")] + public int InitialState { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs new file mode 100644 index 000000000..56c5ddf1a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentState.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public enum QBittorrentState + { + Start = 0, + ForceStart = 1, + Pause = 2 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs new file mode 100644 index 000000000..dbfceb0c3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs @@ -0,0 +1,57 @@ +using System.Numerics; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // torrent properties from the list returned by /query/torrents + public class QBittorrentTorrent + { + public string Hash { get; set; } // Torrent hash + + public string Name { get; set; } // Torrent name + + public long Size { get; set; } // Torrent size (bytes) + + public double Progress { get; set; } // Torrent progress (%/100) + + public BigInteger Eta { get; set; } // Torrent ETA (seconds) (QBit contains a bug exceeding ulong limits) + + public string State { get; set; } // Torrent state. See possible values here below + + public string Label { get; set; } // Label of the torrent + public string Category { get; set; } // Category of the torrent (3.3.5+) + + [JsonProperty(PropertyName = "save_path")] + public string SavePath { get; set; } // Torrent save path + + [JsonProperty(PropertyName = "content_path")] + public string ContentPath { get; set; } // Torrent save path + + public float Ratio { get; set; } // Torrent share ratio + + [JsonProperty(PropertyName = "ratio_limit")] // Per torrent seeding ratio limit (-2 = use global, -1 = unlimited) + public float RatioLimit { get; set; } = -2; + + [JsonProperty(PropertyName = "seeding_time")] + public long? SeedingTime { get; set; } // Torrent seeding time (not provided by the list api) + + [JsonProperty(PropertyName = "seeding_time_limit")] // Per torrent seeding time limit (-2 = use global, -1 = unlimited) + public long SeedingTimeLimit { get; set; } = -2; + } + + public class QBittorrentTorrentProperties + { + public string Hash { get; set; } // Torrent hash + + [JsonProperty(PropertyName = "save_path")] + public string SavePath { get; set; } + + [JsonProperty(PropertyName = "seeding_time")] + public long SeedingTime { get; set; } // Torrent seeding time + } + + public class QBittorrentTorrentFile + { + public string Name { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs new file mode 100644 index 000000000..246b5b558 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdPriorityTypeConverter.cs @@ -0,0 +1,29 @@ +using System; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters +{ + public class SabnzbdPriorityTypeConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var priorityType = (SabnzbdPriority)value; + writer.WriteValue(priorityType.ToString()); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var queuePriority = reader.Value.ToString(); + + SabnzbdPriority output; + Enum.TryParse(queuePriority, out output); + + return output; + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(SabnzbdPriority); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdQueueTimeConverter.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdQueueTimeConverter.cs new file mode 100644 index 000000000..a94c7811a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdQueueTimeConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters +{ + public class SabnzbdQueueTimeConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var ts = (TimeSpan)value; + writer.WriteValue(ts.ToString()); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var split = reader.Value.ToString().Split(':').Select(int.Parse).ToArray(); + + switch (split.Length) + { + case 4: + return new TimeSpan((split[0] * 24) + split[1], split[2], split[3]); + case 3: + return new TimeSpan(split[0], split[1], split[2]); + default: + throw new ArgumentException("Expected either 0:0:0:0 or 0:0:0 format, but received: " + reader.Value); + } + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(TimeSpan); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdStringArrayConverter.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdStringArrayConverter.cs new file mode 100644 index 000000000..bca2353a1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/JsonConverters/SabnzbdStringArrayConverter.cs @@ -0,0 +1,46 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters +{ + /// + /// On some properties sab serializes array of single item as plain string. + /// + public class SabnzbdStringArrayConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var stringArray = (string[])value; + writer.WriteStartArray(); + + for (int i = 0; i < stringArray.Length; i++) + { + writer.WriteValue(stringArray[i]); + } + + writer.WriteEnd(); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.String || reader.TokenType == JsonToken.Null) + { + return new string[] { JValue.Load(reader).ToObject() }; + } + else if (reader.TokenType == JsonToken.StartArray) + { + return JArray.Load(reader).ToObject(); + } + else + { + throw new JsonReaderException("Expected array"); + } + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(string[]); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdAddResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdAddResponse.cs new file mode 100644 index 000000000..147bfce68 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdAddResponse.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdAddResponse + { + public SabnzbdAddResponse() + { + Ids = new List(); + } + + public bool Status { get; set; } + + [JsonProperty(PropertyName = "nzo_ids")] + public List Ids { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdCategoryResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdCategoryResponse.cs new file mode 100644 index 000000000..4a1cba832 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdCategoryResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdCategoryResponse + { + public SabnzbdCategoryResponse() + { + Categories = new List(); + } + + public List Categories { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdConfigResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdConfigResponse.cs new file mode 100644 index 000000000..244d6ad48 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdConfigResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdConfigResponse + { + public SabnzbdConfig Config { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdFullStatusResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdFullStatusResponse.cs new file mode 100644 index 000000000..4ba37f66d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdFullStatusResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdFullStatusResponse + { + public SabnzbdFullStatus Status { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdRetryResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdRetryResponse.cs new file mode 100644 index 000000000..126e9c6ae --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdRetryResponse.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdRetryResponse + { + public bool Status { get; set; } + + [JsonProperty(PropertyName = "nzo_id")] + public string Id { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdVersionResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdVersionResponse.cs new file mode 100644 index 000000000..fd281a58f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdVersionResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdVersionResponse + { + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs new file mode 100644 index 000000000..25076a1fc --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class Sabnzbd : UsenetClientBase + { + private readonly ISabnzbdProxy _proxy; + + public Sabnzbd(ISabnzbdProxy proxy, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IValidateNzbs nzbValidationService, + Logger logger) + : base(httpClient, configService, diskProvider, nzbValidationService, logger) + { + _proxy = proxy; + } + + // patch can be a number (releases) or 'x' (git) + private static readonly Regex VersionRegex = new Regex(@"(?\d+)\.(?\d+)\.(?\d+|x)", RegexOptions.Compiled); + + protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContent) + { + var category = Settings.Category; + var priority = Settings.Priority; + + var response = _proxy.DownloadNzb(fileContent, filename, category, priority, Settings); + + if (response == null || response.Ids.Empty()) + { + throw new DownloadClientRejectedReleaseException(release, "SABnzbd rejected the NZB for an unknown reason"); + } + + return response.Ids.First(); + } + + protected override string AddFromLink(ReleaseInfo release) + { + var category = Settings.Category; + var priority = Settings.Priority; + + var response = _proxy.DownloadNzbByUrl(release.DownloadUrl, category, priority, Settings); + + if (response == null || response.Ids.Empty()) + { + throw new DownloadClientRejectedReleaseException(release, "SABnzbd rejected the NZB for an unknown reason"); + } + + return response.Ids.First(); + } + + public override string Name => "SABnzbd"; + + protected IEnumerable GetCategories(SabnzbdConfig config) + { + var completeDir = new OsPath(config.Misc.complete_dir); + + if (!completeDir.IsRooted) + { + if (HasVersion(2, 0)) + { + var status = _proxy.GetFullStatus(Settings); + completeDir = new OsPath(status.CompleteDir); + } + else + { + var queue = _proxy.GetQueue(0, 1, Settings); + var defaultRootFolder = new OsPath(queue.DefaultRootFolder); + + completeDir = defaultRootFolder + completeDir; + } + } + + foreach (var category in config.Categories) + { + var relativeDir = new OsPath(category.Dir.TrimEnd('*')); + + category.FullPath = completeDir + relativeDir; + + yield return category; + } + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnectionAndVersion()); + failures.AddIfNotNull(TestAuthentication()); + failures.AddIfNotNull(TestGlobalConfig()); + failures.AddIfNotNull(TestCategory()); + } + + private bool HasVersion(int major, int minor, int patch = 0) + { + var rawVersion = _proxy.GetVersion(Settings); + var version = ParseVersion(rawVersion); + + if (version == null) + { + return false; + } + + if (version.Major > major) + { + return true; + } + else if (version.Major < major) + { + return false; + } + + if (version.Minor > minor) + { + return true; + } + else if (version.Minor < minor) + { + return false; + } + + if (version.Build > patch) + { + return true; + } + else if (version.Build < patch) + { + return false; + } + + return true; + } + + private Version ParseVersion(string version) + { + if (version.IsNullOrWhiteSpace()) + { + return null; + } + + var parsed = VersionRegex.Match(version); + + int major; + int minor; + int patch; + + if (parsed.Success) + { + major = Convert.ToInt32(parsed.Groups["major"].Value); + minor = Convert.ToInt32(parsed.Groups["minor"].Value); + patch = Convert.ToInt32(parsed.Groups["patch"].Value.Replace("x", "0")); + } + else + { + if (!version.Equals("develop", StringComparison.InvariantCultureIgnoreCase)) + { + return null; + } + + major = 1; + minor = 1; + patch = 0; + } + + return new Version(major, minor, patch); + } + + private ValidationFailure TestConnectionAndVersion() + { + try + { + var rawVersion = _proxy.GetVersion(Settings); + var version = ParseVersion(rawVersion); + + if (version == null) + { + return new ValidationFailure("Version", "Unknown Version: " + rawVersion); + } + + if (rawVersion.Equals("develop", StringComparison.InvariantCultureIgnoreCase)) + { + return new NzbDroneValidationFailure("Version", "SABnzbd develop version, assuming version 1.1.0 or higher.") + { + IsWarning = true, + DetailedDescription = "Prowlarr may not be able to support new features added to SABnzbd when running develop versions." + }; + } + + if (version.Major >= 1) + { + return null; + } + + if (version.Minor >= 7) + { + return null; + } + + return new ValidationFailure("Version", "Version 0.7.0+ is required, but found: " + version); + } + catch (Exception ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure("Host", "Unable to connect to SABnzbd") + { + DetailedDescription = ex.Message + }; + } + } + + private ValidationFailure TestAuthentication() + { + try + { + _proxy.GetConfig(Settings); + } + catch (Exception ex) + { + if (ex.Message.ContainsIgnoreCase("API Key Incorrect")) + { + return new ValidationFailure("APIKey", "API Key Incorrect"); + } + + if (ex.Message.ContainsIgnoreCase("API Key Required")) + { + return new ValidationFailure("APIKey", "API Key Required"); + } + + throw; + } + + return null; + } + + private ValidationFailure TestGlobalConfig() + { + var config = _proxy.GetConfig(Settings); + if (config.Misc.pre_check && !HasVersion(1, 1)) + { + return new NzbDroneValidationFailure("", "Disable 'Check before download' option in SABnzbd") + { + InfoLink = _proxy.GetBaseUrl(Settings, "config/switches/"), + DetailedDescription = "Using Check before download affects Prowlarr ability to track new downloads. Also SABnzbd recommends 'Abort jobs that cannot be completed' instead since it's more effective." + }; + } + + return null; + } + + private ValidationFailure TestCategory() + { + var config = _proxy.GetConfig(Settings); + var category = GetCategories(config).FirstOrDefault((SabnzbdCategory v) => v.Name == Settings.Category); + + if (category != null) + { + if (category.Dir.EndsWith("*")) + { + return new NzbDroneValidationFailure("Category", "Enable Job folders") + { + InfoLink = _proxy.GetBaseUrl(Settings, "config/categories/"), + DetailedDescription = "Prowlarr prefers each download to have a separate folder. With * appended to the Folder/Path SABnzbd will not create these job folders. Go to SABnzbd to fix it." + }; + } + } + else + { + if (!Settings.Category.IsNullOrWhiteSpace()) + { + return new NzbDroneValidationFailure("Category", "Category does not exist") + { + InfoLink = _proxy.GetBaseUrl(Settings, "config/categories/"), + DetailedDescription = "The category you entered doesn't exist in SABnzbd. Go to SABnzbd to create it." + }; + } + } + + if (config.Misc.enable_tv_sorting && ContainsCategory(config.Misc.tv_categories, Settings.Category)) + { + return new NzbDroneValidationFailure("Category", "Disable TV Sorting") + { + InfoLink = _proxy.GetBaseUrl(Settings, "config/sorting/"), + DetailedDescription = "You must disable SABnzbd TV Sorting for the category Prowlarr uses to prevent import issues. Go to SABnzbd to fix it." + }; + } + + if (config.Misc.enable_movie_sorting && ContainsCategory(config.Misc.movie_categories, Settings.Category)) + { + return new NzbDroneValidationFailure("Category", "Disable Movie Sorting") + { + InfoLink = _proxy.GetBaseUrl(Settings, "config/sorting/"), + DetailedDescription = "You must disable SABnzbd Movie Sorting for the category Prowlarr uses to prevent import issues. Go to SABnzbd to fix it." + }; + } + + if (config.Misc.enable_date_sorting && ContainsCategory(config.Misc.date_categories, Settings.Category)) + { + return new NzbDroneValidationFailure("Category", "Disable Date Sorting") + { + InfoLink = _proxy.GetBaseUrl(Settings, "config/sorting/"), + DetailedDescription = "You must disable SABnzbd Date Sorting for the category Prowlarr uses to prevent import issues. Go to SABnzbd to fix it." + }; + } + + return null; + } + + private bool ContainsCategory(IEnumerable categories, string category) + { + if (categories == null || categories.Empty()) + { + return true; + } + + if (category.IsNullOrWhiteSpace()) + { + category = "Default"; + } + + return categories.Contains(category); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs new file mode 100644 index 000000000..61b5f9228 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdCategory.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdConfig + { + public SabnzbdConfigMisc Misc { get; set; } + + public List Categories { get; set; } + + public List Servers { get; set; } + } + + public class SabnzbdConfigMisc + { + public string complete_dir { get; set; } + public string[] tv_categories { get; set; } + public bool enable_tv_sorting { get; set; } + public string[] movie_categories { get; set; } + public bool enable_movie_sorting { get; set; } + [JsonConverter(typeof(SabnzbdStringArrayConverter))] + public string[] date_categories { get; set; } + public bool enable_date_sorting { get; set; } + public bool pre_check { get; set; } + } + + public class SabnzbdCategory + { + public int Priority { get; set; } + public string PP { get; set; } + public string Name { get; set; } + public string Script { get; set; } + public string Dir { get; set; } + + public OsPath FullPath { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdDownloadStatus.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdDownloadStatus.cs new file mode 100644 index 000000000..9b49edc8a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdDownloadStatus.cs @@ -0,0 +1,22 @@ +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public enum SabnzbdDownloadStatus + { + Grabbing, + Queued, + Paused, + Checking, + Downloading, + QuickCheck, + Verifying, + Repairing, + Fetching, // Fetching additional blocks + Extracting, + Moving, + Running, // Running PP Script + Completed, + Failed, + Deleted, + Propagating + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdFullStatus.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdFullStatus.cs new file mode 100644 index 000000000..f8b3298c1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdFullStatus.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdFullStatus + { + // Added in Sabnzbd 2.0.0, my_home was previously in &mode=queue. + // This is the already resolved completedir path. + [JsonProperty(PropertyName = "completedir")] + public string CompleteDir { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistory.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistory.cs new file mode 100644 index 000000000..b19786739 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistory.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdHistory + { + public bool Paused { get; set; } + + [JsonProperty(PropertyName = "slots")] + public List Items { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistoryItem.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistoryItem.cs new file mode 100644 index 000000000..39e6bbd71 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdHistoryItem.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdHistoryItem + { + [JsonProperty(PropertyName = "fail_message")] + public string FailMessage { get; set; } + + [JsonProperty(PropertyName = "bytes")] + public long Size { get; set; } + public string Category { get; set; } + + [JsonProperty(PropertyName = "nzb_name")] + public string NzbName { get; set; } + + [JsonProperty(PropertyName = "download_time")] + public int DownloadTime { get; set; } + + public string Storage { get; set; } + public SabnzbdDownloadStatus Status { get; set; } + + [JsonProperty(PropertyName = "nzo_id")] + public string Id { get; set; } + + [JsonProperty(PropertyName = "name")] + public string Title { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdJsonError.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdJsonError.cs new file mode 100644 index 000000000..d723e710b --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdJsonError.cs @@ -0,0 +1,13 @@ +using System; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdJsonError + { + public string Status { get; set; } + public string Error { get; set; } + + public bool Failed => !string.IsNullOrWhiteSpace(Status) && + Status.Equals("false", StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdPriority.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdPriority.cs new file mode 100644 index 000000000..905aea6c0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdPriority.cs @@ -0,0 +1,12 @@ +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public enum SabnzbdPriority + { + Default = -100, + Paused = -2, + Low = -1, + Normal = 0, + High = 1, + Force = 2 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs new file mode 100644 index 000000000..c876850c1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs @@ -0,0 +1,254 @@ +using System; +using System.Net; +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Download.Clients.Sabnzbd.Responses; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public interface ISabnzbdProxy + { + string GetBaseUrl(SabnzbdSettings settings, string relativePath = null); + SabnzbdAddResponse DownloadNzb(byte[] nzbData, string filename, string category, int priority, SabnzbdSettings settings); + SabnzbdAddResponse DownloadNzbByUrl(string url, string category, int priority, SabnzbdSettings settings); + void RemoveFrom(string source, string id, bool deleteData, SabnzbdSettings settings); + string GetVersion(SabnzbdSettings settings); + SabnzbdConfig GetConfig(SabnzbdSettings settings); + SabnzbdFullStatus GetFullStatus(SabnzbdSettings settings); + SabnzbdQueue GetQueue(int start, int limit, SabnzbdSettings settings); + SabnzbdHistory GetHistory(int start, int limit, string category, SabnzbdSettings settings); + string RetryDownload(string id, SabnzbdSettings settings); + } + + public class SabnzbdProxy : ISabnzbdProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public SabnzbdProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public string GetBaseUrl(SabnzbdSettings settings, string relativePath = null) + { + var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + baseUrl = HttpUri.CombinePath(baseUrl, relativePath); + + return baseUrl; + } + + public SabnzbdAddResponse DownloadNzb(byte[] nzbData, string filename, string category, int priority, SabnzbdSettings settings) + { + var request = BuildRequest("addfile", settings).Post(); + + request.AddQueryParam("cat", category); + request.AddQueryParam("priority", priority); + + request.AddFormUpload("name", filename, nzbData, "application/x-nzb"); + + SabnzbdAddResponse response; + + if (!Json.TryDeserialize(ProcessRequest(request, settings), out response)) + { + response = new SabnzbdAddResponse(); + response.Status = true; + } + + return response; + } + + public SabnzbdAddResponse DownloadNzbByUrl(string url, string category, int priority, SabnzbdSettings settings) + { + var request = BuildRequest("addurl", settings).Post(); + + request.AddQueryParam("name", url); + request.AddQueryParam("cat", category); + request.AddQueryParam("priority", priority); + + SabnzbdAddResponse response; + + if (!Json.TryDeserialize(ProcessRequest(request, settings), out response)) + { + response = new SabnzbdAddResponse(); + response.Status = true; + } + + return response; + } + + public void RemoveFrom(string source, string id, bool deleteData, SabnzbdSettings settings) + { + var request = BuildRequest(source, settings); + request.AddQueryParam("name", "delete"); + request.AddQueryParam("del_files", deleteData ? 1 : 0); + request.AddQueryParam("value", id); + + ProcessRequest(request, settings); + } + + public string GetVersion(SabnzbdSettings settings) + { + var request = BuildRequest("version", settings); + + SabnzbdVersionResponse response; + + if (!Json.TryDeserialize(ProcessRequest(request, settings), out response)) + { + response = new SabnzbdVersionResponse(); + } + + return response.Version; + } + + public SabnzbdConfig GetConfig(SabnzbdSettings settings) + { + var request = BuildRequest("get_config", settings); + + var response = Json.Deserialize(ProcessRequest(request, settings)); + + return response.Config; + } + + public SabnzbdFullStatus GetFullStatus(SabnzbdSettings settings) + { + var request = BuildRequest("fullstatus", settings); + request.AddQueryParam("skip_dashboard", "1"); + + var response = Json.Deserialize(ProcessRequest(request, settings)); + + return response.Status; + } + + public SabnzbdQueue GetQueue(int start, int limit, SabnzbdSettings settings) + { + var request = BuildRequest("queue", settings); + request.AddQueryParam("start", start); + request.AddQueryParam("limit", limit); + + var response = ProcessRequest(request, settings); + + return Json.Deserialize(JObject.Parse(response).SelectToken("queue").ToString()); + } + + public SabnzbdHistory GetHistory(int start, int limit, string category, SabnzbdSettings settings) + { + var request = BuildRequest("history", settings); + request.AddQueryParam("start", start); + request.AddQueryParam("limit", limit); + + if (category.IsNotNullOrWhiteSpace()) + { + request.AddQueryParam("category", category); + } + + var response = ProcessRequest(request, settings); + + return Json.Deserialize(JObject.Parse(response).SelectToken("history").ToString()); + } + + public string RetryDownload(string id, SabnzbdSettings settings) + { + var request = BuildRequest("retry", settings); + request.AddQueryParam("value", id); + + SabnzbdRetryResponse response; + + if (!Json.TryDeserialize(ProcessRequest(request, settings), out response)) + { + response = new SabnzbdRetryResponse(); + response.Status = true; + } + + return response.Id; + } + + private HttpRequestBuilder BuildRequest(string mode, SabnzbdSettings settings) + { + var baseUrl = GetBaseUrl(settings, "api"); + + var requestBuilder = new HttpRequestBuilder(baseUrl) + .Accept(HttpAccept.Json) + .AddQueryParam("mode", mode); + + requestBuilder.LogResponseContent = true; + + if (settings.ApiKey.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddSuffixQueryParam("apikey", settings.ApiKey); + } + else + { + requestBuilder.AddSuffixQueryParam("ma_username", settings.Username); + requestBuilder.AddSuffixQueryParam("ma_password", settings.Password); + } + + requestBuilder.AddSuffixQueryParam("output", "json"); + + return requestBuilder; + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder, SabnzbdSettings settings) + { + var httpRequest = requestBuilder.Build(); + + HttpResponse response; + + _logger.Debug("Url: {0}", httpRequest.Url); + + try + { + response = _httpClient.Execute(httpRequest); + } + catch (HttpException ex) + { + throw new DownloadClientException("Unable to connect to SABnzbd, {0}", ex, ex.Message); + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to SABnzbd, certificate validation failed.", ex); + } + + throw new DownloadClientUnavailableException("Unable to connect to SABnzbd, {0}", ex, ex.Message); + } + + CheckForError(response); + + return response.Content; + } + + private void CheckForError(HttpResponse response) + { + SabnzbdJsonError result; + + if (!Json.TryDeserialize(response.Content, out result)) + { + //Handle plain text responses from SAB + result = new SabnzbdJsonError(); + + if (response.Content.StartsWith("error", StringComparison.InvariantCultureIgnoreCase)) + { + result.Status = "false"; + result.Error = response.Content.Replace("error: ", ""); + } + else + { + result.Status = "true"; + } + + result.Error = response.Content.Replace("error: ", ""); + } + + if (result.Failed) + { + throw new DownloadClientException("Error response received from SABnzbd: {0}", result.Error); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs new file mode 100644 index 000000000..405d9dec9 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdQueue + { + // Removed in Sabnzbd 2.0.0, see mode=fullstatus instead. + [JsonProperty(PropertyName = "my_home")] + public string DefaultRootFolder { get; set; } + + public bool Paused { get; set; } + + [JsonProperty(PropertyName = "slots")] + public List Items { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueueItem.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueueItem.cs new file mode 100644 index 000000000..78e80f52c --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueueItem.cs @@ -0,0 +1,35 @@ +using System; +using Newtonsoft.Json; +using NzbDrone.Core.Download.Clients.Sabnzbd.JsonConverters; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdQueueItem + { + public SabnzbdDownloadStatus Status { get; set; } + public int Index { get; set; } + + [JsonConverter(typeof(SabnzbdQueueTimeConverter))] + public TimeSpan Timeleft { get; set; } + + [JsonProperty(PropertyName = "mb")] + public decimal Size { get; set; } + + [JsonProperty(PropertyName = "filename")] + public string Title { get; set; } + + [JsonConverter(typeof(SabnzbdPriorityTypeConverter))] + public SabnzbdPriority Priority { get; set; } + + [JsonProperty(PropertyName = "cat")] + public string Category { get; set; } + + [JsonProperty(PropertyName = "mbleft")] + public decimal Sizeleft { get; set; } + + public int Percentage { get; set; } + + [JsonProperty(PropertyName = "nzo_id")] + public string Id { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs new file mode 100644 index 000000000..024f55a81 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs @@ -0,0 +1,79 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdSettingsValidator : AbstractValidator + { + public SabnzbdSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + + RuleFor(c => c.ApiKey).NotEmpty() + .WithMessage("API Key is required when username/password are not configured") + .When(c => string.IsNullOrWhiteSpace(c.Username)); + + RuleFor(c => c.Username).NotEmpty() + .WithMessage("Username is required when API key is not configured") + .When(c => string.IsNullOrWhiteSpace(c.ApiKey)); + + RuleFor(c => c.Password).NotEmpty() + .WithMessage("Password is required when API key is not configured") + .When(c => string.IsNullOrWhiteSpace(c.ApiKey)); + + RuleFor(c => c.Category).NotEmpty() + .WithMessage("A category is recommended") + .AsWarning(); + } + } + + public class SabnzbdSettings : IProviderConfig + { + private static readonly SabnzbdSettingsValidator Validator = new SabnzbdSettingsValidator(); + + public SabnzbdSettings() + { + Host = "localhost"; + Port = 8080; + Category = "prowlarr"; + Priority = (int)SabnzbdPriority.Default; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Sabnzbd")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the Sabnzbd url, e.g. http://[host]:[port]/[urlBase]/api")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "API Key", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)] + public string ApiKey { get; set; } + + [FieldDefinition(5, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(6, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(7, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + public string Category { get; set; } + + [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing items")] + public int Priority { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/TorrentSeedConfiguration.cs b/src/NzbDrone.Core/Download/Clients/TorrentSeedConfiguration.cs new file mode 100644 index 000000000..9c4b279dc --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/TorrentSeedConfiguration.cs @@ -0,0 +1,12 @@ +using System; + +namespace NzbDrone.Core.Download.Clients +{ + public class TorrentSeedConfiguration + { + public static TorrentSeedConfiguration DefaultConfiguration = new TorrentSeedConfiguration(); + + public double? Ratio { get; set; } + public TimeSpan? SeedTime { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs new file mode 100644 index 000000000..ef53c1978 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/Transmission.cs @@ -0,0 +1,43 @@ +using System; +using System.Text.RegularExpressions; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class Transmission : TransmissionBase + { + public Transmission(ITransmissionProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(proxy, torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + } + + protected override ValidationFailure ValidateVersion() + { + var versionString = _proxy.GetClientVersion(Settings); + + _logger.Debug("Transmission version information: {0}", versionString); + + var versionResult = Regex.Match(versionString, @"(? "Transmission"; + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs new file mode 100644 index 000000000..089d74556 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs @@ -0,0 +1,183 @@ +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.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public abstract class TransmissionBase : TorrentClientBase + { + protected readonly ITransmissionProxy _proxy; + + public TransmissionBase(ITransmissionProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + _proxy = proxy; + } + + protected bool HasReachedSeedLimit(TransmissionTorrent torrent, double? ratio, Lazy config) + { + var isStopped = torrent.Status == TransmissionTorrentStatus.Stopped; + var isSeeding = torrent.Status == TransmissionTorrentStatus.Seeding; + + if (torrent.SeedRatioMode == 1) + { + if (isStopped && ratio.HasValue && ratio >= torrent.SeedRatioLimit) + { + return true; + } + } + else if (torrent.SeedRatioMode == 0) + { + if (isStopped && config.Value.SeedRatioLimited && ratio >= config.Value.SeedRatioLimit) + { + return true; + } + } + + // Transmission doesn't support SeedTimeLimit, use/abuse seed idle limit, but only if it was set per-torrent. + if (torrent.SeedIdleMode == 1) + { + if ((isStopped || isSeeding) && torrent.SecondsSeeding > torrent.SeedIdleLimit * 60) + { + return true; + } + } + else if (torrent.SeedIdleMode == 0) + { + // The global idle limit is a real idle limit, if it's configured then 'Stopped' is enough. + if (isStopped && config.Value.IdleSeedingLimitEnabled) + { + return true; + } + } + + return false; + } + + protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + { + _proxy.AddTorrentFromUrl(magnetLink, GetDownloadDirectory(), Settings); + + //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); + if (Settings.Priority == (int)TransmissionPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash, Settings); + } + + return hash; + } + + protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + { + _proxy.AddTorrentFromData(fileContent, GetDownloadDirectory(), Settings); + + //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); + if (Settings.Priority == (int)TransmissionPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash, Settings); + } + + return hash; + } + + protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + { + throw new NotImplementedException(); + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.HasErrors()) + { + return; + } + + failures.AddIfNotNull(TestGetTorrents()); + } + + protected virtual OsPath GetOutputPath(OsPath outputPath, TransmissionTorrent torrent) + { + return outputPath + torrent.Name.Replace(":", "_"); + } + + protected string GetDownloadDirectory() + { + if (Settings.Directory.IsNotNullOrWhiteSpace()) + { + return Settings.Directory; + } + + if (!Settings.Category.IsNotNullOrWhiteSpace()) + { + return null; + } + + var config = _proxy.GetConfig(Settings); + var destDir = config.DownloadDir; + + return $"{destDir.TrimEnd('/')}/{Settings.Category}"; + } + + protected ValidationFailure TestConnection() + { + try + { + return ValidateVersion(); + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure("Username", "Authentication failure") + { + DetailedDescription = string.Format("Please verify your username and password. Also verify if the host running Prowlarr isn't blocked from accessing {0} by WhiteList limitations in the {0} configuration.", Name) + }; + } + catch (DownloadClientUnavailableException ex) + { + _logger.Error(ex, ex.Message); + + return new NzbDroneValidationFailure("Host", "Unable to connect to Transmission") + { + DetailedDescription = ex.Message + }; + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to test"); + + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + } + } + + protected abstract ValidationFailure ValidateVersion(); + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to get torrents"); + return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionConfig.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionConfig.cs new file mode 100644 index 000000000..1b96ca6d3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionConfig.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class TransmissionConfig + { + [JsonProperty("rpc-version")] + public string RpcVersion { get; set; } + public string Version { get; set; } + + [JsonProperty("download-dir")] + public string DownloadDir { get; set; } + + public double SeedRatioLimit { get; set; } + public bool SeedRatioLimited { get; set; } + + [JsonProperty("idle-seeding-limit")] + public long IdleSeedingLimit { get; set; } + [JsonProperty("idle-seeding-limit-enabled")] + public bool IdleSeedingLimitEnabled { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionException.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionException.cs new file mode 100644 index 000000000..3b91b4ce3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionException.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class TransmissionException : DownloadClientException + { + public TransmissionException(string message) + : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionPriority.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionPriority.cs new file mode 100644 index 000000000..1cf99c501 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionPriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public enum TransmissionPriority + { + Last = 0, + First = 1 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs new file mode 100644 index 000000000..b30d14497 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionProxy.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.Net; +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public interface ITransmissionProxy + { + List GetTorrents(TransmissionSettings settings); + void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, TransmissionSettings settings); + void AddTorrentFromData(byte[] torrentData, string downloadDirectory, TransmissionSettings settings); + void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, TransmissionSettings settings); + TransmissionConfig GetConfig(TransmissionSettings settings); + string GetProtocolVersion(TransmissionSettings settings); + string GetClientVersion(TransmissionSettings settings); + void RemoveTorrent(string hash, bool removeData, TransmissionSettings settings); + void MoveTorrentToTopInQueue(string hashString, TransmissionSettings settings); + } + + public class TransmissionProxy : ITransmissionProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + private ICached _authSessionIDCache; + + public TransmissionProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authSessionIDCache = cacheManager.GetCache(GetType(), "authSessionID"); + } + + public List GetTorrents(TransmissionSettings settings) + { + var result = GetTorrentStatus(settings); + + var torrents = ((JArray)result.Arguments["torrents"]).ToObject>(); + + return torrents; + } + + public void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("filename", torrentUrl); + arguments.Add("paused", settings.AddPaused); + + if (!downloadDirectory.IsNullOrWhiteSpace()) + { + arguments.Add("download-dir", downloadDirectory); + } + + ProcessRequest("torrent-add", arguments, settings); + } + + public void AddTorrentFromData(byte[] torrentData, string downloadDirectory, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("metainfo", Convert.ToBase64String(torrentData)); + arguments.Add("paused", settings.AddPaused); + + if (!downloadDirectory.IsNullOrWhiteSpace()) + { + arguments.Add("download-dir", downloadDirectory); + } + + ProcessRequest("torrent-add", arguments, settings); + } + + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, TransmissionSettings settings) + { + if (seedConfiguration == null) + { + return; + } + + var arguments = new Dictionary(); + arguments.Add("ids", new[] { hash }); + + if (seedConfiguration.Ratio != null) + { + arguments.Add("seedRatioLimit", seedConfiguration.Ratio.Value); + arguments.Add("seedRatioMode", 1); + } + + if (seedConfiguration.SeedTime != null) + { + arguments.Add("seedIdleLimit", Convert.ToInt32(seedConfiguration.SeedTime.Value.TotalMinutes)); + arguments.Add("seedIdleMode", 1); + } + + ProcessRequest("torrent-set", arguments, settings); + } + + public string GetProtocolVersion(TransmissionSettings settings) + { + var config = GetConfig(settings); + + return config.RpcVersion; + } + + public string GetClientVersion(TransmissionSettings settings) + { + var config = GetConfig(settings); + + return config.Version; + } + + public TransmissionConfig GetConfig(TransmissionSettings settings) + { + // Gets the transmission version. + var result = GetSessionVariables(settings); + + return Json.Deserialize(result.Arguments.ToJson()); + } + + public void RemoveTorrent(string hashString, bool removeData, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("ids", new string[] { hashString }); + arguments.Add("delete-local-data", removeData); + + ProcessRequest("torrent-remove", arguments, settings); + } + + public void MoveTorrentToTopInQueue(string hashString, TransmissionSettings settings) + { + var arguments = new Dictionary(); + arguments.Add("ids", new string[] { hashString }); + + ProcessRequest("queue-move-top", arguments, settings); + } + + private TransmissionResponse GetSessionVariables(TransmissionSettings settings) + { + // Retrieve transmission information such as the default download directory, bandwith throttling and seed ratio. + return ProcessRequest("session-get", null, settings); + } + + private TransmissionResponse GetSessionStatistics(TransmissionSettings settings) + { + return ProcessRequest("session-stats", null, settings); + } + + private TransmissionResponse GetTorrentStatus(TransmissionSettings settings) + { + return GetTorrentStatus(null, settings); + } + + private TransmissionResponse GetTorrentStatus(IEnumerable hashStrings, TransmissionSettings settings) + { + var fields = new string[] + { + "id", + "hashString", // Unique torrent ID. Use this instead of the client id? + "name", + "downloadDir", + "totalSize", + "leftUntilDone", + "isFinished", + "eta", + "status", + "secondsDownloading", + "secondsSeeding", + "errorString", + "uploadedEver", + "downloadedEver", + "seedRatioLimit", + "seedRatioMode", + "seedIdleLimit", + "seedIdleMode", + "fileCount" + }; + + var arguments = new Dictionary(); + arguments.Add("fields", fields); + + if (hashStrings != null) + { + arguments.Add("ids", hashStrings); + } + + var result = ProcessRequest("torrent-get", arguments, settings); + + return result; + } + + private HttpRequestBuilder BuildRequest(TransmissionSettings settings) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + .Resource("rpc") + .Accept(HttpAccept.Json); + + requestBuilder.LogResponseContent = true; + requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + requestBuilder.AllowAutoRedirect = false; + + return requestBuilder; + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, TransmissionSettings settings, bool reauthenticate = false) + { + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var sessionId = _authSessionIDCache.Find(authKey); + + if (sessionId == null || reauthenticate) + { + _authSessionIDCache.Remove(authKey); + + var authLoginRequest = BuildRequest(settings).Build(); + authLoginRequest.SuppressHttpError = true; + + var response = _httpClient.Execute(authLoginRequest); + if (response.StatusCode == HttpStatusCode.MovedPermanently) + { + var url = response.Headers.GetSingleValue("Location"); + + throw new DownloadClientException("Remote site redirected to " + url); + } + else if (response.StatusCode == HttpStatusCode.Conflict) + { + sessionId = response.Headers.GetSingleValue("X-Transmission-Session-Id"); + + if (sessionId == null) + { + throw new DownloadClientException("Remote host did not return a Session Id."); + } + } + else + { + throw new DownloadClientAuthenticationException("Failed to authenticate with Transmission."); + } + + _logger.Debug("Transmission authentication succeeded."); + + _authSessionIDCache.Set(authKey, sessionId); + } + + requestBuilder.SetHeader("X-Transmission-Session-Id", sessionId); + } + + public TransmissionResponse ProcessRequest(string action, object arguments, TransmissionSettings settings) + { + try + { + var requestBuilder = BuildRequest(settings); + requestBuilder.Headers.ContentType = "application/json"; + requestBuilder.SuppressHttpError = true; + + AuthenticateClient(requestBuilder, settings); + + var request = requestBuilder.Post().Build(); + + var data = new Dictionary(); + data.Add("method", action); + + if (arguments != null) + { + data.Add("arguments", arguments); + } + + request.SetContent(data.ToJson()); + request.ContentSummary = string.Format("{0}(...)", action); + + var response = _httpClient.Execute(request); + + if (response.StatusCode == HttpStatusCode.Conflict) + { + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Post().Build(); + + request.SetContent(data.ToJson()); + request.ContentSummary = string.Format("{0}(...)", action); + + response = _httpClient.Execute(request); + } + else if (response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new DownloadClientAuthenticationException("User authentication failed."); + } + + var transmissionResponse = Json.Deserialize(response.Content); + + if (transmissionResponse == null) + { + throw new TransmissionException("Unexpected response"); + } + else if (transmissionResponse.Result != "success") + { + throw new TransmissionException(transmissionResponse.Result); + } + + return transmissionResponse; + } + catch (HttpException ex) + { + throw new DownloadClientException("Unable to connect to Transmission, please check your settings", ex); + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to Transmission, certificate validation failed.", ex); + } + + throw new DownloadClientUnavailableException("Unable to connect to Transmission, please check your settings", ex); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs new file mode 100644 index 000000000..5d16754b7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionResponse.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class TransmissionResponse + { + public string Result { get; set; } + public Dictionary Arguments { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs new file mode 100644 index 000000000..0759cf1ea --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs @@ -0,0 +1,73 @@ +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.Transmission +{ + public class TransmissionSettingsValidator : AbstractValidator + { + public TransmissionSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + + RuleFor(c => c.UrlBase).ValidUrlBase(); + + RuleFor(c => c.Category).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -"); + + RuleFor(c => c.Category).Empty() + .When(c => c.Directory.IsNotNullOrWhiteSpace()) + .WithMessage("Cannot use Category and Directory"); + } + } + + public class TransmissionSettings : IProviderConfig + { + private static readonly TransmissionSettingsValidator Validator = new TransmissionSettingsValidator(); + + public TransmissionSettings() + { + Host = "localhost"; + Port = 9091; + UrlBase = "/transmission/"; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to Transmission")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the transmission rpc url, eg http://[host]:[port]/[urlBase]/rpc, defaults to '/transmission/'")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] + public string Category { get; set; } + + [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default Transmission location")] + public string Directory { get; set; } + + [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing items")] + public int Priority { get; set; } + + [FieldDefinition(9, Label = "Add Paused", Type = FieldType.Checkbox)] + public bool AddPaused { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs new file mode 100644 index 000000000..3abb5d4e8 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs @@ -0,0 +1,25 @@ +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public class TransmissionTorrent + { + public int Id { get; set; } + public string HashString { get; set; } + public string Name { get; set; } + public string DownloadDir { get; set; } + public long TotalSize { get; set; } + public long LeftUntilDone { get; set; } + public bool IsFinished { get; set; } + public int Eta { get; set; } + public TransmissionTorrentStatus Status { get; set; } + public int SecondsDownloading { get; set; } + public int SecondsSeeding { get; set; } + public string ErrorString { get; set; } + public long DownloadedEver { get; set; } + public long UploadedEver { get; set; } + public double SeedRatioLimit { get; set; } + public int SeedRatioMode { get; set; } + public long SeedIdleLimit { get; set; } + public int SeedIdleMode { get; set; } + public int FileCount { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs new file mode 100644 index 000000000..13e40f04e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrentStatus.cs @@ -0,0 +1,13 @@ +namespace NzbDrone.Core.Download.Clients.Transmission +{ + public enum TransmissionTorrentStatus + { + Stopped = 0, + CheckWait = 1, + Check = 2, + Queued = 3, + Downloading = 4, + SeedingWait = 5, + Seeding = 6 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs new file mode 100644 index 000000000..d52b3eb46 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Vuze/Vuze.cs @@ -0,0 +1,62 @@ +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.Transmission; + +namespace NzbDrone.Core.Download.Clients.Vuze +{ + public class Vuze : TransmissionBase + { + private const int MINIMUM_SUPPORTED_PROTOCOL_VERSION = 14; + + public Vuze(ITransmissionProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(proxy, torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + } + + protected override OsPath GetOutputPath(OsPath outputPath, TransmissionTorrent torrent) + { + // Vuze has similar behavior as uTorrent: + // - A multi-file torrent is downloaded in a job folder and 'outputPath' points to that directory directly. + // - A single-file torrent is downloaded in the root folder and 'outputPath' poinst to that root folder. + // We have to make sure the return value points to the job folder OR file. + if (outputPath == default || outputPath.FileName == torrent.Name || torrent.FileCount > 1) + { + _logger.Trace("Vuze output directory: {0}", outputPath); + } + else + { + outputPath = outputPath + torrent.Name; + _logger.Trace("Vuze output file: {0}", outputPath); + } + + return outputPath; + } + + protected override ValidationFailure ValidateVersion() + { + var versionString = _proxy.GetProtocolVersion(Settings); + + _logger.Debug("Vuze protocol version information: {0}", versionString); + + int version; + if (!int.TryParse(versionString, out version) || version < MINIMUM_SUPPORTED_PROTOCOL_VERSION) + { + { + return new ValidationFailure(string.Empty, "Protocol version not supported, use Vuze 5.0.0.0 or higher with Vuze Web Remote plugin."); + } + } + + return null; + } + + public override string Name => "Vuze"; + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs new file mode 100644 index 000000000..eeee3eba4 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.rTorrent; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public class RTorrent : TorrentClientBase + { + private readonly IRTorrentProxy _proxy; + private readonly IRTorrentDirectoryValidator _rTorrentDirectoryValidator; + + public RTorrent(IRTorrentProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IRTorrentDirectoryValidator rTorrentDirectoryValidator, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + _proxy = proxy; + _rTorrentDirectoryValidator = rTorrentDirectoryValidator; + } + + protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + { + var priority = (RTorrentPriority)Settings.Priority; + + _proxy.AddTorrentFromUrl(magnetLink, Settings.Category, priority, Settings.Directory, Settings); + + var tries = 10; + var retryDelay = 500; + + // Wait a bit for the magnet to be resolved. + if (!WaitForTorrent(hash, tries, retryDelay)) + { + _logger.Warn("rTorrent could not resolve magnet within {0} seconds, download may remain stuck: {1}.", tries * retryDelay / 1000, magnetLink); + + return hash; + } + + return hash; + } + + protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + { + var priority = (RTorrentPriority)Settings.Priority; + + _proxy.AddTorrentFromFile(filename, fileContent, Settings.Category, priority, Settings.Directory, Settings); + + var tries = 10; + var retryDelay = 500; + if (!WaitForTorrent(hash, tries, retryDelay)) + { + _logger.Debug("rTorrent didn't add the torrent within {0} seconds: {1}.", tries * retryDelay / 1000, filename); + + throw new ReleaseDownloadException(release, "Downloading torrent failed"); + } + + return hash; + } + + public override string Name => "rTorrent"; + + public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to remove torrents that have finished seeding when using rTorrent", ProviderMessageType.Warning); + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.HasErrors()) + { + return; + } + + failures.AddIfNotNull(TestGetTorrents()); + failures.AddIfNotNull(TestDirectory()); + } + + private ValidationFailure TestConnection() + { + try + { + var version = _proxy.GetVersion(Settings); + + if (new Version(version) < new Version("0.9.0")) + { + return new ValidationFailure(string.Empty, "rTorrent version should be at least 0.9.0. Version reported is {0}", version); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to test rTorrent"); + + return new NzbDroneValidationFailure("Host", "Unable to connect to rTorrent") + { + DetailedDescription = ex.Message + }; + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to get torrents"); + return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + + private ValidationFailure TestDirectory() + { + var result = _rTorrentDirectoryValidator.Validate(Settings); + + if (result.IsValid) + { + return null; + } + + return result.Errors.First(); + } + + private bool WaitForTorrent(string hash, int tries, int retryDelay) + { + for (var i = 0; i < tries; i++) + { + if (_proxy.HasHashTorrent(hash, Settings)) + { + return true; + } + + Thread.Sleep(retryDelay); + } + + _logger.Debug("Could not find hash {0} in {1} tries at {2} ms intervals.", hash, tries, retryDelay); + + return false; + } + + protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentDirectoryValidator.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentDirectoryValidator.cs new file mode 100644 index 000000000..4b0e6f38a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentDirectoryValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Download.Clients.RTorrent; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.Download.Clients.rTorrent +{ + public interface IRTorrentDirectoryValidator + { + ValidationResult Validate(RTorrentSettings instance); + } + + public class RTorrentDirectoryValidator : AbstractValidator, IRTorrentDirectoryValidator + { + public RTorrentDirectoryValidator(PathExistsValidator pathExistsValidator, + MappedNetworkDriveValidator mappedNetworkDriveValidator) + { + RuleFor(c => c.Directory).Cascade(CascadeMode.StopOnFirstFailure) + .IsValidPath() + .SetValidator(mappedNetworkDriveValidator) + .SetValidator(pathExistsValidator) + .When(c => c.Directory.IsNotNullOrWhiteSpace()) + .When(c => c.Host == "localhost" || c.Host == "127.0.0.1"); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs new file mode 100644 index 000000000..99b289e8e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public enum RTorrentPriority + { + DoNotDownload = 0, + Low = 1, + Normal = 2, + High = 3 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs new file mode 100644 index 000000000..c7f72952f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Net; +using CookComputing.XmlRpc; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public interface IRTorrentProxy + { + string GetVersion(RTorrentSettings settings); + List GetTorrents(RTorrentSettings settings); + + void AddTorrentFromUrl(string torrentUrl, string label, RTorrentPriority priority, string directory, RTorrentSettings settings); + void AddTorrentFromFile(string fileName, byte[] fileContent, string label, RTorrentPriority priority, string directory, RTorrentSettings settings); + void RemoveTorrent(string hash, RTorrentSettings settings); + void SetTorrentLabel(string hash, string label, RTorrentSettings settings); + bool HasHashTorrent(string hash, RTorrentSettings settings); + } + + public interface IRTorrent : IXmlRpcProxy + { + [XmlRpcMethod("d.multicall2")] + object[] TorrentMulticall(params string[] parameters); + + [XmlRpcMethod("load.normal")] + int LoadNormal(string target, string data, params string[] commands); + + [XmlRpcMethod("load.start")] + int LoadStart(string target, string data, params string[] commands); + + [XmlRpcMethod("load.raw")] + int LoadRaw(string target, byte[] data, params string[] commands); + + [XmlRpcMethod("load.raw_start")] + int LoadRawStart(string target, byte[] data, params string[] commands); + + [XmlRpcMethod("d.erase")] + int Remove(string hash); + + [XmlRpcMethod("d.name")] + string GetName(string hash); + + [XmlRpcMethod("d.custom1.set")] + string SetLabel(string hash, string label); + + [XmlRpcMethod("system.client_version")] + string GetVersion(); + } + + public class RTorrentProxy : IRTorrentProxy + { + private readonly Logger _logger; + + public RTorrentProxy(Logger logger) + { + _logger = logger; + } + + public string GetVersion(RTorrentSettings settings) + { + _logger.Debug("Executing remote method: system.client_version"); + + var client = BuildClient(settings); + var version = ExecuteRequest(() => client.GetVersion()); + + return version; + } + + public List GetTorrents(RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.multicall2"); + + var client = BuildClient(settings); + var ret = ExecuteRequest(() => client.TorrentMulticall( + "", + "", + "d.name=", // string + "d.hash=", // string + "d.base_path=", // string + "d.custom1=", // string (label) + "d.size_bytes=", // long + "d.left_bytes=", // long + "d.down.rate=", // long (in bytes / s) + "d.ratio=", // long + "d.is_open=", // long + "d.is_active=", // long + "d.complete=")); //long + + _logger.Trace(ret.ToJson()); + + var items = new List(); + + foreach (object[] torrent in ret) + { + var labelDecoded = System.Web.HttpUtility.UrlDecode((string)torrent[3]); + + var item = new RTorrentTorrent(); + item.Name = (string)torrent[0]; + item.Hash = (string)torrent[1]; + item.Path = (string)torrent[2]; + item.Category = labelDecoded; + item.TotalSize = (long)torrent[4]; + item.RemainingSize = (long)torrent[5]; + item.DownRate = (long)torrent[6]; + item.Ratio = (long)torrent[7]; + item.IsOpen = Convert.ToBoolean((long)torrent[8]); + item.IsActive = Convert.ToBoolean((long)torrent[9]); + item.IsFinished = Convert.ToBoolean((long)torrent[10]); + + items.Add(item); + } + + return items; + } + + public void AddTorrentFromUrl(string torrentUrl, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) + { + var client = BuildClient(settings); + var response = ExecuteRequest(() => + { + if (settings.AddStopped) + { + _logger.Debug("Executing remote method: load.normal"); + return client.LoadNormal("", torrentUrl, GetCommands(label, priority, directory)); + } + else + { + _logger.Debug("Executing remote method: load.start"); + return client.LoadStart("", torrentUrl, GetCommands(label, priority, directory)); + } + }); + + if (response != 0) + { + throw new DownloadClientException("Could not add torrent: {0}.", torrentUrl); + } + } + + public void AddTorrentFromFile(string fileName, byte[] fileContent, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) + { + var client = BuildClient(settings); + var response = ExecuteRequest(() => + { + if (settings.AddStopped) + { + _logger.Debug("Executing remote method: load.raw"); + return client.LoadRaw("", fileContent, GetCommands(label, priority, directory)); + } + else + { + _logger.Debug("Executing remote method: load.raw_start"); + return client.LoadRawStart("", fileContent, GetCommands(label, priority, directory)); + } + }); + + if (response != 0) + { + throw new DownloadClientException("Could not add torrent: {0}.", fileName); + } + } + + public void SetTorrentLabel(string hash, string label, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.custom1.set"); + + var client = BuildClient(settings); + var response = ExecuteRequest(() => client.SetLabel(hash, label)); + + if (response != label) + { + throw new DownloadClientException("Could not set label to {1} for torrent: {0}.", hash, label); + } + } + + public void RemoveTorrent(string hash, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.erase"); + + var client = BuildClient(settings); + var response = ExecuteRequest(() => client.Remove(hash)); + + if (response != 0) + { + throw new DownloadClientException("Could not remove torrent: {0}.", hash); + } + } + + public bool HasHashTorrent(string hash, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.name"); + + var client = BuildClient(settings); + + try + { + var name = ExecuteRequest(() => client.GetName(hash)); + + if (name.IsNullOrWhiteSpace()) + { + return false; + } + + var metaTorrent = name == (hash + ".meta"); + + return !metaTorrent; + } + catch (Exception) + { + return false; + } + } + + private string[] GetCommands(string label, RTorrentPriority priority, string directory) + { + var result = new List(); + + if (label.IsNotNullOrWhiteSpace()) + { + result.Add("d.custom1.set=" + label); + } + + if (priority != RTorrentPriority.Normal) + { + result.Add("d.priority.set=" + (int)priority); + } + + if (directory.IsNotNullOrWhiteSpace()) + { + result.Add("d.directory.set=" + directory); + } + + return result.ToArray(); + } + + private IRTorrent BuildClient(RTorrentSettings settings) + { + var client = XmlRpcProxyGen.Create(); + + client.Url = string.Format(@"{0}://{1}:{2}/{3}", + settings.UseSsl ? "https" : "http", + settings.Host, + settings.Port, + settings.UrlBase); + + client.EnableCompression = true; + + if (!settings.Username.IsNullOrWhiteSpace()) + { + client.Credentials = new NetworkCredential(settings.Username, settings.Password); + } + + return client; + } + + private T ExecuteRequest(Func task) + { + try + { + return task(); + } + catch (XmlRpcServerException ex) + { + throw new DownloadClientException("Unable to connect to rTorrent, please check your settings", ex); + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to rTorrent, certificate validation failed.", ex); + } + + throw new DownloadClientUnavailableException("Unable to connect to rTorrent, please check your settings", ex); + } + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs new file mode 100644 index 000000000..0fad0741e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs @@ -0,0 +1,68 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public class RTorrentSettingsValidator : AbstractValidator + { + public RTorrentSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.Category).NotEmpty() + .WithMessage("A category is recommended") + .AsWarning(); + } + } + + public class RTorrentSettings : IProviderConfig + { + private static readonly RTorrentSettingsValidator Validator = new RTorrentSettingsValidator(); + + public RTorrentSettings() + { + Host = "localhost"; + Port = 8080; + UrlBase = "RPC2"; + Category = "prowlarr"; + Priority = (int)RTorrentPriority.Normal; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to ruTorrent")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Path", Type = FieldType.Textbox, HelpText = "Path to the XMLRPC endpoint, see http(s)://[host]:[port]/[urlPath]. When using ruTorrent this usually is RPC2 or (path to ruTorrent)/plugins/rpc/rpc.php")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional.")] + public string Category { get; set; } + + [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default rTorrent location")] + public string Directory { get; set; } + + [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing items")] + public int Priority { get; set; } + + [FieldDefinition(9, Label = "Add Stopped", Type = FieldType.Checkbox, HelpText = "Enabling will prevent magnets from downloading before downloading")] + public bool AddStopped { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs new file mode 100644 index 000000000..d00df188f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs @@ -0,0 +1,17 @@ +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public class RTorrentTorrent + { + public string Name { get; set; } + public string Hash { get; set; } + public string Path { get; set; } + public string Category { get; set; } + public long TotalSize { get; set; } + public long RemainingSize { get; set; } + public long DownRate { get; set; } + public long Ratio { get; set; } + public bool IsFinished { get; set; } + public bool IsOpen { get; set; } + public bool IsActive { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs new file mode 100644 index 000000000..47951fe70 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +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.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public class UTorrent : TorrentClientBase + { + private readonly IUTorrentProxy _proxy; + private readonly ICached _torrentCache; + + public UTorrent(IUTorrentProxy proxy, + ICacheManager cacheManager, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, logger) + { + _proxy = proxy; + + _torrentCache = cacheManager.GetCache(GetType(), "differentialTorrents"); + } + + protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) + { + _proxy.AddTorrentFromUrl(magnetLink, Settings); + + //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + _proxy.SetTorrentLabel(hash, Settings.Category, Settings); + } + + if (Settings.Priority == (int)UTorrentPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash, Settings); + } + + _proxy.SetState(hash, (UTorrentState)Settings.IntialState, Settings); + + return hash; + } + + protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) + { + _proxy.AddTorrentFromFile(filename, fileContent, Settings); + + //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); + if (Settings.Category.IsNotNullOrWhiteSpace()) + { + _proxy.SetTorrentLabel(hash, Settings.Category, Settings); + } + + if (Settings.Priority == (int)UTorrentPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash, Settings); + } + + _proxy.SetState(hash, (UTorrentState)Settings.IntialState, Settings); + + return hash; + } + + public override string Name => "uTorrent"; + + private List GetTorrents() + { + List torrents; + + var cacheKey = string.Format("{0}:{1}:{2}", Settings.Host, Settings.Port, Settings.Category); + var cache = _torrentCache.Find(cacheKey); + + var response = _proxy.GetTorrents(cache == null ? null : cache.CacheID, Settings); + + if (cache != null && response.Torrents == null) + { + var removedAndUpdated = new HashSet(response.TorrentsChanged.Select(v => v.Hash).Concat(response.TorrentsRemoved)); + + torrents = cache.Torrents + .Where(v => !removedAndUpdated.Contains(v.Hash)) + .Concat(response.TorrentsChanged) + .ToList(); + } + else + { + torrents = response.Torrents; + } + + cache = new UTorrentTorrentCache + { + CacheID = response.CacheNumber, + Torrents = torrents + }; + + _torrentCache.Set(cacheKey, cache, TimeSpan.FromMinutes(15)); + + return torrents; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.HasErrors()) + { + return; + } + + failures.AddIfNotNull(TestGetTorrents()); + } + + private ValidationFailure TestConnection() + { + try + { + var version = _proxy.GetVersion(Settings); + + if (version < 25406) + { + return new ValidationFailure(string.Empty, "Old uTorrent client with unsupported API, need 3.0 or higher"); + } + } + catch (DownloadClientAuthenticationException ex) + { + _logger.Error(ex, ex.Message); + return new NzbDroneValidationFailure("Username", "Authentication failure") + { + DetailedDescription = "Please verify your username and password." + }; + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to connect to uTorrent"); + 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, "Failed to test uTorrent"); + + return new NzbDroneValidationFailure("Host", "Unable to connect to uTorrent") + { + DetailedDescription = ex.Message + }; + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(null, Settings); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to get torrents"); + return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + + protected override string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentPriority.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentPriority.cs new file mode 100644 index 000000000..0079da670 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentPriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public enum UTorrentPriority + { + Last = 0, + First = 1 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs new file mode 100644 index 000000000..252cca7a1 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public interface IUTorrentProxy + { + int GetVersion(UTorrentSettings settings); + Dictionary GetConfig(UTorrentSettings settings); + UTorrentResponse GetTorrents(string cacheID, UTorrentSettings settings); + + void AddTorrentFromUrl(string torrentUrl, UTorrentSettings settings); + void AddTorrentFromFile(string fileName, byte[] fileContent, UTorrentSettings settings); + void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, UTorrentSettings settings); + + void RemoveTorrent(string hash, bool removeData, UTorrentSettings settings); + void SetTorrentLabel(string hash, string label, UTorrentSettings settings); + void RemoveTorrentLabel(string hash, string label, UTorrentSettings settings); + void MoveTorrentToTopInQueue(string hash, UTorrentSettings settings); + void SetState(string hash, UTorrentState state, UTorrentSettings settings); + } + + public class UTorrentProxy : IUTorrentProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + private readonly ICached> _authCookieCache; + private readonly ICached _authTokenCache; + + public UTorrentProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + _authTokenCache = cacheManager.GetCache(GetType(), "authTokens"); + } + + public int GetVersion(UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", "getsettings"); + + var result = ProcessRequest(requestBuilder, settings); + + return result.Build; + } + + public Dictionary GetConfig(UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", "getsettings"); + + var result = ProcessRequest(requestBuilder, settings); + + var configuration = new Dictionary(); + + foreach (var configItem in result.Settings) + { + configuration.Add(configItem[0].ToString(), configItem[2].ToString()); + } + + return configuration; + } + + public UTorrentResponse GetTorrents(string cacheId, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("list", 1); + + if (cacheId.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("cid", cacheId); + } + + var result = ProcessRequest(requestBuilder, settings); + + return result; + } + + public void AddTorrentFromUrl(string torrentUrl, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", "add-url") + .AddQueryParam("s", torrentUrl); + + ProcessRequest(requestBuilder, settings); + } + + public void AddTorrentFromFile(string fileName, byte[] fileContent, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .Post() + .AddQueryParam("action", "add-file") + .AddQueryParam("path", string.Empty) + .AddFormUpload("torrent_file", fileName, fileContent); + + ProcessRequest(requestBuilder, settings); + } + + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, UTorrentSettings settings) + { + if (seedConfiguration == null) + { + return; + } + + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", "setprops") + .AddQueryParam("hash", hash); + + requestBuilder.AddQueryParam("s", "seed_override") + .AddQueryParam("v", 1); + + if (seedConfiguration.Ratio != null) + { + requestBuilder.AddQueryParam("s", "seed_ratio") + .AddQueryParam("v", Convert.ToInt32(seedConfiguration.Ratio.Value * 1000)); + } + + if (seedConfiguration.SeedTime != null) + { + requestBuilder.AddQueryParam("s", "seed_time") + .AddQueryParam("v", Convert.ToInt32(seedConfiguration.SeedTime.Value.TotalSeconds)); + } + + ProcessRequest(requestBuilder, settings); + } + + public void RemoveTorrent(string hash, bool removeData, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", removeData ? "removedata" : "remove") + .AddQueryParam("hash", hash); + + ProcessRequest(requestBuilder, settings); + } + + public void SetTorrentLabel(string hash, string label, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", "setprops") + .AddQueryParam("hash", hash); + + requestBuilder.AddQueryParam("s", "label") + .AddQueryParam("v", label); + + ProcessRequest(requestBuilder, settings); + } + + public void RemoveTorrentLabel(string hash, string label, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", "setprops") + .AddQueryParam("hash", hash); + + requestBuilder.AddQueryParam("s", "label") + .AddQueryParam("v", label) + .AddQueryParam("s", "label") + .AddQueryParam("v", ""); + + ProcessRequest(requestBuilder, settings); + } + + public void MoveTorrentToTopInQueue(string hash, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", "queuetop") + .AddQueryParam("hash", hash); + + ProcessRequest(requestBuilder, settings); + } + + public void SetState(string hash, UTorrentState state, UTorrentSettings settings) + { + var requestBuilder = BuildRequest(settings) + .AddQueryParam("action", state.ToString().ToLowerInvariant()) + .AddQueryParam("hash", hash); + + ProcessRequest(requestBuilder, settings); + } + + private HttpRequestBuilder BuildRequest(UTorrentSettings settings) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + .Resource("/gui/") + .KeepAlive() + .SetHeader("Cache-Control", "no-cache") + .Accept(HttpAccept.Json); + + requestBuilder.LogResponseContent = true; + requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + + return requestBuilder; + } + + public UTorrentResponse ProcessRequest(HttpRequestBuilder requestBuilder, UTorrentSettings settings) + { + AuthenticateClient(requestBuilder, settings); + + var request = requestBuilder.Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.BadRequest || ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Debug("Authentication required, logging in."); + + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Build(); + + response = _httpClient.Execute(request); + } + else + { + throw new DownloadClientException("Unable to connect to uTorrent, please check your settings", ex); + } + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new DownloadClientUnavailableException("Unable to connect to uTorrent, certificate validation failed.", ex); + } + + throw new DownloadClientException("Unable to connect to uTorrent, please check your settings", ex); + } + + return Json.Deserialize(response.Content); + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, UTorrentSettings settings, bool reauthenticate = false) + { + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var cookies = _authCookieCache.Find(authKey); + var authToken = _authTokenCache.Find(authKey); + + if (cookies == null || authToken == null || reauthenticate) + { + _authCookieCache.Remove(authKey); + _authTokenCache.Remove(authKey); + + var authLoginRequest = BuildRequest(settings).Resource("/gui/token.html").Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(authLoginRequest); + _logger.Debug("uTorrent authentication succeeded."); + + var xmlDoc = new System.Xml.XmlDocument(); + xmlDoc.LoadXml(response.Content); + + authToken = xmlDoc.FirstChild.FirstChild.InnerText; + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Debug("uTorrent authentication failed."); + throw new DownloadClientAuthenticationException("Failed to authenticate with uTorrent."); + } + + throw new DownloadClientException("Unable to connect to uTorrent, please check your settings", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Unable to connect to uTorrent, please check your settings", ex); + } + + cookies = response.GetCookies(); + + _authCookieCache.Set(authKey, cookies); + _authTokenCache.Set(authKey, authToken); + } + + requestBuilder.SetCookies(cookies); + requestBuilder.AddPrefixQueryParam("token", authToken, true); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentResponse.cs new file mode 100644 index 000000000..659e7f53c --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentResponse.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public class UTorrentResponse + { + public int Build { get; set; } + public List Torrents { get; set; } + public List Label { get; set; } + public List RssFeeds { get; set; } + public List RssFilters { get; set; } + + [JsonProperty(PropertyName = "torrentp")] + public List TorrentsChanged { get; set; } + + [JsonProperty(PropertyName = "torrentm")] + public List TorrentsRemoved { get; set; } + + [JsonProperty(PropertyName = "torrentc")] + public string CacheNumber { get; set; } + + public List Settings { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs new file mode 100644 index 000000000..19388bf5d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentSettings.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public class UTorrentSettingsValidator : AbstractValidator + { + public UTorrentSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + RuleFor(c => c.Category).NotEmpty(); + } + } + + public class UTorrentSettings : IProviderConfig + { + private static readonly UTorrentSettingsValidator Validator = new UTorrentSettingsValidator(); + + public UTorrentSettings() + { + Host = "localhost"; + Port = 8080; + Category = "prowlarr"; + } + + [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 = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use secure connection when connecting to uTorrent")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the uTorrent url, e.g. http://[host]:[port]/[urlBase]/api")] + public string UrlBase { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox, Privacy = PrivacyLevel.UserName)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] + public string Category { get; set; } + + [FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing items")] + public int Priority { get; set; } + + [FieldDefinition(8, Label = "Initial State", Type = FieldType.Select, SelectOptions = typeof(UTorrentState), HelpText = "Initial state for torrents added to uTorrent")] + public int IntialState { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs new file mode 100644 index 000000000..027b138e0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrent.cs @@ -0,0 +1,117 @@ +using System; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + [JsonConverter(typeof(UTorrentTorrentJsonConverter))] + public class UTorrentTorrent + { + public string Hash { get; set; } + public UTorrentTorrentStatus Status { get; set; } + public string Name { get; set; } + public long Size { get; set; } + public double Progress { get; set; } + public long Downloaded { get; set; } + public long Uploaded { get; set; } + public double Ratio { get; set; } + public int UploadSpeed { get; set; } + public int DownloadSpeed { get; set; } + + public int Eta { get; set; } + public string Label { get; set; } + public int PeersConnected { get; set; } + public int PeersInSwarm { get; set; } + public int SeedsConnected { get; set; } + public int SeedsInSwarm { get; set; } + public double Availablity { get; set; } + public int TorrentQueueOrder { get; set; } + public long Remaining { get; set; } + public string DownloadUrl { get; set; } + + public object RssFeedUrl { get; set; } + public object StatusMessage { get; set; } + public object StreamId { get; set; } + public object DateAdded { get; set; } + public object DateCompleted { get; set; } + public object AppUpdateUrl { get; set; } + public string RootDownloadPath { get; set; } + public object Unknown27 { get; set; } + public object Unknown28 { get; set; } + } + + internal class UTorrentTorrentJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType == typeof(UTorrentTorrent); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new UTorrentTorrent(); + + result.Hash = reader.ReadAsString(); + result.Status = (UTorrentTorrentStatus)reader.ReadAsInt32(); + result.Name = reader.ReadAsString(); + reader.Read(); + result.Size = (long)reader.Value; + result.Progress = (int)reader.ReadAsInt32() / 1000.0; + reader.Read(); + result.Downloaded = (long)reader.Value; + reader.Read(); + result.Uploaded = (long)reader.Value; + result.Ratio = (int)reader.ReadAsInt32() / 1000.0; + result.UploadSpeed = (int)reader.ReadAsInt32(); + result.DownloadSpeed = (int)reader.ReadAsInt32(); + + result.Eta = (int)reader.ReadAsInt32(); + result.Label = reader.ReadAsString(); + result.PeersConnected = (int)reader.ReadAsInt32(); + result.PeersInSwarm = (int)reader.ReadAsInt32(); + result.SeedsConnected = (int)reader.ReadAsInt32(); + result.SeedsInSwarm = (int)reader.ReadAsInt32(); + result.Availablity = (int)reader.ReadAsInt32() / 65536.0; + result.TorrentQueueOrder = (int)reader.ReadAsInt32(); + reader.Read(); + result.Remaining = (long)reader.Value; + + reader.Read(); + + // Builds before 25406 don't return the remaining items. + if (reader.TokenType != JsonToken.EndArray) + { + result.DownloadUrl = (string)reader.Value; + + reader.Read(); + result.RssFeedUrl = reader.Value; + reader.Read(); + result.StatusMessage = reader.Value; + reader.Read(); + result.StreamId = reader.Value; + reader.Read(); + result.DateAdded = reader.Value; + reader.Read(); + result.DateCompleted = reader.Value; + reader.Read(); + result.AppUpdateUrl = reader.Value; + result.RootDownloadPath = reader.ReadAsString(); + reader.Read(); + result.Unknown27 = reader.Value; + reader.Read(); + result.Unknown28 = reader.Value; + + while (reader.TokenType != JsonToken.EndArray) + { + reader.Read(); + } + } + + return result; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentCache.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentCache.cs new file mode 100644 index 000000000..4d9424f39 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentCache.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public class UTorrentTorrentCache + { + public string CacheID { get; set; } + + public List Torrents { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentStatus.cs new file mode 100644 index 000000000..eb4f57da8 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentTorrentStatus.cs @@ -0,0 +1,17 @@ +using System; + +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + [Flags] + public enum UTorrentTorrentStatus + { + Started = 1, + Checking = 2, + StartAfterCheck = 4, + Checked = 8, + Error = 16, + Paused = 32, + Queued = 64, + Loaded = 128 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UtorrentState.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UtorrentState.cs new file mode 100644 index 000000000..17feaa485 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UtorrentState.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Download.Clients.UTorrent +{ + public enum UTorrentState + { + Start = 0, + ForceStart = 1, + Pause = 2, + Stop = 3 + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs new file mode 100644 index 000000000..26b37d249 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download +{ + public abstract class DownloadClientBase : IDownloadClient + where TSettings : IProviderConfig, new() + { + protected readonly IConfigService _configService; + protected readonly IDiskProvider _diskProvider; + protected readonly Logger _logger; + + public abstract string Name { get; } + + public Type ConfigContract => typeof(TSettings); + + public virtual ProviderMessage Message => null; + + public IEnumerable DefaultDefinitions => new List(); + + public ProviderDefinition Definition { get; set; } + + public virtual object RequestAction(string action, IDictionary query) + { + return null; + } + + protected TSettings Settings => (TSettings)Definition.Settings; + + protected DownloadClientBase(IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + { + _configService = configService; + _diskProvider = diskProvider; + _logger = logger; + } + + public override string ToString() + { + return GetType().Name; + } + + public abstract DownloadProtocol Protocol + { + get; + } + + public abstract string Download(ReleaseInfo release, bool redirect); + + public ValidationResult Test() + { + var failures = new List(); + + try + { + Test(failures); + } + catch (Exception ex) + { + _logger.Error(ex, "Test aborted due to exception"); + failures.Add(new ValidationFailure(string.Empty, "Test was aborted due to an error: " + ex.Message)); + } + + return new ValidationResult(failures); + } + + protected abstract void Test(List failures); + + protected ValidationFailure TestFolder(string folder, string propertyName, bool mustBeWritable = true) + { + if (!_diskProvider.FolderExists(folder)) + { + return new NzbDroneValidationFailure(propertyName, "Folder does not exist") + { + DetailedDescription = string.Format("The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account '{0}', which is used to execute Prowlarr.", Environment.UserName) + }; + } + + if (mustBeWritable && !_diskProvider.FolderWritable(folder)) + { + _logger.Error("Folder '{0}' is not writable.", folder); + return new NzbDroneValidationFailure(propertyName, "Unable to write to folder") + { + DetailedDescription = string.Format("The folder you specified is not writable. Please verify the folder permissions for the user account '{0}', which is used to execute Prowlarr.", Environment.UserName) + }; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientDefinition.cs b/src/NzbDrone.Core/Download/DownloadClientDefinition.cs new file mode 100644 index 000000000..1c0dfa927 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientDefinition.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Indexers; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public class DownloadClientDefinition : ProviderDefinition + { + public DownloadProtocol Protocol { get; set; } + public int Priority { get; set; } = 1; + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientFactory.cs b/src/NzbDrone.Core/Download/DownloadClientFactory.cs new file mode 100644 index 000000000..d69374794 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientFactory.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClientFactory : IProviderFactory + { + List DownloadHandlingEnabled(bool filterBlockedClients = true); + } + + public class DownloadClientFactory : ProviderFactory, IDownloadClientFactory + { + private readonly IDownloadClientStatusService _downloadClientStatusService; + private readonly Logger _logger; + + public DownloadClientFactory(IDownloadClientStatusService downloadClientStatusService, + IDownloadClientRepository providerRepository, + IEnumerable providers, + IServiceProvider container, + IEventAggregator eventAggregator, + Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) + { + _downloadClientStatusService = downloadClientStatusService; + _logger = logger; + } + + protected override List Active() + { + return base.Active().Where(c => c.Enable).ToList(); + } + + public override void SetProviderCharacteristics(IDownloadClient provider, DownloadClientDefinition definition) + { + base.SetProviderCharacteristics(provider, definition); + + definition.Protocol = provider.Protocol; + } + + public List DownloadHandlingEnabled(bool filterBlockedClients = true) + { + var enabledClients = GetAvailableProviders(); + + if (filterBlockedClients) + { + return FilterBlockedClients(enabledClients).ToList(); + } + + return enabledClients.ToList(); + } + + private IEnumerable FilterBlockedClients(IEnumerable clients) + { + var blockedIndexers = _downloadClientStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v); + + foreach (var client in clients) + { + DownloadClientStatus downloadClientStatus; + if (blockedIndexers.TryGetValue(client.Definition.Id, out downloadClientStatus)) + { + _logger.Debug("Temporarily ignoring download client {0} till {1} due to recent failures.", client.Definition.Name, downloadClientStatus.DisabledTill.Value.ToLocalTime()); + continue; + } + + yield return client; + } + } + + public override ValidationResult Test(DownloadClientDefinition definition) + { + var result = base.Test(definition); + + if ((result == null || result.IsValid) && definition.Id != 0) + { + _downloadClientStatusService.RecordSuccess(definition.Id); + } + + return result; + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientProvider.cs b/src/NzbDrone.Core/Download/DownloadClientProvider.cs new file mode 100644 index 000000000..3ad8f6615 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Download +{ + public interface IProvideDownloadClient + { + IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol); + IEnumerable GetDownloadClients(); + IDownloadClient Get(int id); + } + + public class DownloadClientProvider : IProvideDownloadClient + { + private readonly Logger _logger; + private readonly IDownloadClientFactory _downloadClientFactory; + private readonly IDownloadClientStatusService _downloadClientStatusService; + private readonly ICached _lastUsedDownloadClient; + + public DownloadClientProvider(IDownloadClientStatusService downloadClientStatusService, IDownloadClientFactory downloadClientFactory, ICacheManager cacheManager, Logger logger) + { + _logger = logger; + _downloadClientFactory = downloadClientFactory; + _downloadClientStatusService = downloadClientStatusService; + _lastUsedDownloadClient = cacheManager.GetCache(GetType(), "lastDownloadClientId"); + } + + public IDownloadClient GetDownloadClient(DownloadProtocol downloadProtocol) + { + var availableProviders = _downloadClientFactory.GetAvailableProviders().Where(v => v.Protocol == downloadProtocol).ToList(); + + if (!availableProviders.Any()) + { + return null; + } + + var blockedProviders = new HashSet(_downloadClientStatusService.GetBlockedProviders().Select(v => v.ProviderId)); + + if (blockedProviders.Any()) + { + var nonBlockedProviders = availableProviders.Where(v => !blockedProviders.Contains(v.Definition.Id)).ToList(); + + if (nonBlockedProviders.Any()) + { + availableProviders = nonBlockedProviders; + } + else + { + _logger.Trace("No non-blocked Download Client available, retrying blocked one."); + } + } + + // Use the first priority clients first + availableProviders = availableProviders.GroupBy(v => (v.Definition as DownloadClientDefinition).Priority) + .OrderBy(v => v.Key) + .First().OrderBy(v => v.Definition.Id).ToList(); + + var lastId = _lastUsedDownloadClient.Find(downloadProtocol.ToString()); + + var provider = availableProviders.FirstOrDefault(v => v.Definition.Id > lastId) ?? availableProviders.First(); + + _lastUsedDownloadClient.Set(downloadProtocol.ToString(), provider.Definition.Id); + + return provider; + } + + public IEnumerable GetDownloadClients() + { + return _downloadClientFactory.GetAvailableProviders(); + } + + public IDownloadClient Get(int id) + { + return _downloadClientFactory.GetAvailableProviders().Single(d => d.Definition.Id == id); + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientRepository.cs b/src/NzbDrone.Core/Download/DownloadClientRepository.cs new file mode 100644 index 000000000..704dababc --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientRepository.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClientRepository : IProviderRepository + { + } + + public class DownloadClientRepository : ProviderRepository, IDownloadClientRepository + { + public DownloadClientRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientStatus.cs b/src/NzbDrone.Core/Download/DownloadClientStatus.cs new file mode 100644 index 000000000..a6d388125 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientStatus.cs @@ -0,0 +1,8 @@ +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Download +{ + public class DownloadClientStatus : ProviderStatusBase + { + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientStatusRepository.cs b/src/NzbDrone.Core/Download/DownloadClientStatusRepository.cs new file mode 100644 index 000000000..f7c2b8f08 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientStatusRepository.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClientStatusRepository : IProviderStatusRepository + { + } + + public class DownloadClientStatusRepository : ProviderStatusRepository, IDownloadClientStatusRepository + { + public DownloadClientStatusRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadClientStatusService.cs b/src/NzbDrone.Core/Download/DownloadClientStatusService.cs new file mode 100644 index 000000000..19f11d4b7 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientStatusService.cs @@ -0,0 +1,22 @@ +using System; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClientStatusService : IProviderStatusServiceBase + { + } + + public class DownloadClientStatusService : ProviderStatusServiceBase, IDownloadClientStatusService + { + public DownloadClientStatusService(IDownloadClientStatusRepository providerStatusRepository, IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, Logger logger) + : base(providerStatusRepository, eventAggregator, runtimeInfo, logger) + { + MinimumTimeSinceInitialFailure = TimeSpan.FromMinutes(5); + MaximumEscalationLevel = 5; + } + } +} diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs new file mode 100644 index 000000000..ddb10cc3f --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadService.cs @@ -0,0 +1,164 @@ +using System; +using System.Threading.Tasks; +using NLog; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Common.TPL; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadService + { + void SendReportToClient(ReleaseInfo release, bool redirect); + Task DownloadReport(string link, int indexerId, string source, string title); + void RecordRedirect(string link, int indexerId, string source, string title); + } + + public class DownloadService : IDownloadService + { + private readonly IProvideDownloadClient _downloadClientProvider; + private readonly IDownloadClientStatusService _downloadClientStatusService; + private readonly IIndexerFactory _indexerFactory; + private readonly IIndexerStatusService _indexerStatusService; + private readonly IRateLimitService _rateLimitService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public DownloadService(IProvideDownloadClient downloadClientProvider, + IDownloadClientStatusService downloadClientStatusService, + IIndexerFactory indexerFactory, + IIndexerStatusService indexerStatusService, + IRateLimitService rateLimitService, + IEventAggregator eventAggregator, + Logger logger) + { + _downloadClientProvider = downloadClientProvider; + _downloadClientStatusService = downloadClientStatusService; + _indexerFactory = indexerFactory; + _indexerStatusService = indexerStatusService; + _rateLimitService = rateLimitService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public void SendReportToClient(ReleaseInfo release, bool redirect) + { + var downloadTitle = release.Title; + var downloadClient = _downloadClientProvider.GetDownloadClient(release.DownloadProtocol); + + if (downloadClient == null) + { + throw new DownloadClientUnavailableException($"{release.DownloadProtocol} Download client isn't configured yet"); + } + + // Get the seed configuration for this release. + // remoteMovie.SeedConfiguration = _seedConfigProvider.GetSeedConfiguration(remoteMovie); + + // Limit grabs to 2 per second. + if (release.DownloadUrl.IsNotNullOrWhiteSpace() && !release.DownloadUrl.StartsWith("magnet:")) + { + var url = new HttpUri(release.DownloadUrl); + _rateLimitService.WaitAndPulse(url.Host, TimeSpan.FromSeconds(2)); + } + + string downloadClientId; + try + { + downloadClientId = downloadClient.Download(release, redirect); + _downloadClientStatusService.RecordSuccess(downloadClient.Definition.Id); + _indexerStatusService.RecordSuccess(release.IndexerId); + } + catch (ReleaseUnavailableException) + { + _logger.Trace("Release {0} no longer available on indexer.", release); + _eventAggregator.PublishEvent(new IndexerDownloadEvent(release.IndexerId, false, release.Source, release.Title, redirect)); + throw; + } + catch (DownloadClientRejectedReleaseException) + { + _logger.Trace("Release {0} rejected by download client, possible duplicate.", release); + _eventAggregator.PublishEvent(new IndexerDownloadEvent(release.IndexerId, false, release.Source, release.Title, redirect)); + throw; + } + catch (ReleaseDownloadException ex) + { + var http429 = ex.InnerException as TooManyRequestsException; + if (http429 != null) + { + _indexerStatusService.RecordFailure(release.IndexerId, http429.RetryAfter); + } + else + { + _indexerStatusService.RecordFailure(release.IndexerId); + } + + _eventAggregator.PublishEvent(new IndexerDownloadEvent(release.IndexerId, false, release.Source, release.Title, redirect)); + + throw; + } + + _logger.ProgressInfo("Report sent to {0}. {1}", downloadClient.Definition.Name, downloadTitle); + + _eventAggregator.PublishEvent(new IndexerDownloadEvent(release.IndexerId, true, release.Source, release.Title, redirect)); + } + + public async Task DownloadReport(string link, int indexerId, string source, string title) + { + var url = new HttpUri(link); + + // Limit grabs to 2 per second. + if (link.IsNotNullOrWhiteSpace() && !link.StartsWith("magnet:")) + { + await _rateLimitService.WaitAndPulseAsync(url.Host, TimeSpan.FromSeconds(2)); + } + + var indexer = _indexerFactory.GetInstance(_indexerFactory.Get(indexerId)); + var success = false; + var downloadedBytes = Array.Empty(); + + try + { + downloadedBytes = await indexer.Download(url); + _indexerStatusService.RecordSuccess(indexerId); + success = true; + } + catch (ReleaseUnavailableException) + { + _logger.Trace("Release {0} no longer available on indexer.", link); + _eventAggregator.PublishEvent(new IndexerDownloadEvent(indexerId, success, source, title)); + throw; + } + catch (ReleaseDownloadException ex) + { + var http429 = ex.InnerException as TooManyRequestsException; + if (http429 != null) + { + _indexerStatusService.RecordFailure(indexerId, http429.RetryAfter); + } + else + { + _indexerStatusService.RecordFailure(indexerId); + } + + _eventAggregator.PublishEvent(new IndexerDownloadEvent(indexerId, success, source, title)); + throw; + } + + _eventAggregator.PublishEvent(new IndexerDownloadEvent(indexerId, success, source, title)); + return downloadedBytes; + } + + public void RecordRedirect(string link, int indexerId, string source, string title) + { + _eventAggregator.PublishEvent(new IndexerDownloadEvent(indexerId, true, source, title, true)); + } + } +} diff --git a/src/NzbDrone.Core/Download/GrabbedEvent.cs b/src/NzbDrone.Core/Download/GrabbedEvent.cs new file mode 100644 index 000000000..73c82a40d --- /dev/null +++ b/src/NzbDrone.Core/Download/GrabbedEvent.cs @@ -0,0 +1,19 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download +{ + public class GrabbedEvent : IEvent + { + public ReleaseInfo Release { get; private set; } + public int DownloadClientId { get; set; } + public string DownloadClient { get; set; } + public string DownloadClientName { get; set; } + public string DownloadId { get; set; } + + public GrabbedEvent(ReleaseInfo release) + { + Release = release; + } + } +} diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs new file mode 100644 index 000000000..a182ce990 --- /dev/null +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadClient : IProvider + { + DownloadProtocol Protocol { get; } + string Download(ReleaseInfo release, bool redirect); + } +} diff --git a/src/NzbDrone.Core/Download/InvalidNzbException.cs b/src/NzbDrone.Core/Download/InvalidNzbException.cs new file mode 100644 index 000000000..5fde39c54 --- /dev/null +++ b/src/NzbDrone.Core/Download/InvalidNzbException.cs @@ -0,0 +1,28 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Download +{ + public class InvalidNzbException : NzbDroneException + { + public InvalidNzbException(string message, params object[] args) + : base(message, args) + { + } + + public InvalidNzbException(string message) + : base(message) + { + } + + public InvalidNzbException(string message, Exception innerException, params object[] args) + : base(message, innerException, args) + { + } + + public InvalidNzbException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Download/NzbValidationService.cs b/src/NzbDrone.Core/Download/NzbValidationService.cs new file mode 100644 index 000000000..e3cbff710 --- /dev/null +++ b/src/NzbDrone.Core/Download/NzbValidationService.cs @@ -0,0 +1,53 @@ +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Download +{ + public interface IValidateNzbs + { + void Validate(string filename, byte[] fileContent); + } + + public class NzbValidationService : IValidateNzbs + { + public void Validate(string filename, byte[] fileContent) + { + var reader = new StreamReader(new MemoryStream(fileContent)); + + using (var xmlTextReader = XmlReader.Create(reader, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, IgnoreComments = true })) + { + var xDoc = XDocument.Load(xmlTextReader); + var nzb = xDoc.Root; + + if (nzb == null) + { + throw new InvalidNzbException("Invalid NZB: No Root element [{0}]", filename); + } + + // nZEDb has an bug in their error reporting code spitting out invalid http status codes + if (nzb.Name.LocalName.Equals("error") && + nzb.TryGetAttributeValue("code", out var code) && + nzb.TryGetAttributeValue("description", out var description)) + { + throw new InvalidNzbException("Invalid NZB: Contains indexer error: {0} - {1}", code, description); + } + + if (!nzb.Name.LocalName.Equals("nzb")) + { + throw new InvalidNzbException("Invalid NZB: Unexpected root element. Expected 'nzb' found '{0}' [{1}]", nzb.Name.LocalName, filename); + } + + var ns = nzb.Name.Namespace; + var files = nzb.Elements(ns + "file").ToList(); + + if (files.Empty()) + { + throw new InvalidNzbException("Invalid NZB: No files [{0}]", filename); + } + } + } + } +} diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs new file mode 100644 index 000000000..cb4b508e0 --- /dev/null +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -0,0 +1,232 @@ +using System; +using System.Net; +using MonoTorrent; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public abstract class TorrentClientBase : DownloadClientBase + where TSettings : IProviderConfig, new() + { + protected readonly IHttpClient _httpClient; + protected readonly ITorrentFileInfoReader _torrentFileInfoReader; + + protected TorrentClientBase(ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + Logger logger) + : base(configService, diskProvider, logger) + { + _httpClient = httpClient; + _torrentFileInfoReader = torrentFileInfoReader; + } + + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + + public virtual bool PreferTorrentFile => false; + + protected abstract string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink); + protected abstract string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent); + protected abstract string AddFromTorrentLink(ReleaseInfo release, string hash, string torrentLink); + + public override string Download(ReleaseInfo release, bool redirect) + { + var torrentInfo = release as TorrentInfo; + + string magnetUrl = null; + string torrentUrl = null; + + if (release.DownloadUrl.IsNotNullOrWhiteSpace() && release.DownloadUrl.StartsWith("magnet:")) + { + magnetUrl = release.DownloadUrl; + } + else + { + torrentUrl = release.DownloadUrl; + } + + if (torrentInfo != null && !torrentInfo.MagnetUrl.IsNullOrWhiteSpace()) + { + magnetUrl = torrentInfo.MagnetUrl; + } + + if (PreferTorrentFile) + { + if (torrentUrl.IsNotNullOrWhiteSpace()) + { + try + { + return DownloadFromWebUrl(release, torrentUrl); + } + catch (Exception ex) + { + if (!magnetUrl.IsNullOrWhiteSpace()) + { + throw; + } + + _logger.Debug("Torrent download failed, trying magnet. ({0})", ex.Message); + } + } + + if (magnetUrl.IsNotNullOrWhiteSpace()) + { + try + { + return DownloadFromMagnetUrl(release, magnetUrl); + } + catch (NotSupportedException ex) + { + throw new ReleaseDownloadException(release, "Magnet not supported by download client. ({0})", ex.Message); + } + } + } + else + { + if (magnetUrl.IsNotNullOrWhiteSpace()) + { + try + { + return DownloadFromMagnetUrl(release, magnetUrl); + } + catch (NotSupportedException ex) + { + if (torrentUrl.IsNullOrWhiteSpace()) + { + throw new ReleaseDownloadException(release, "Magnet not supported by download client. ({0})", ex.Message); + } + + _logger.Debug("Magnet not supported by download client, trying torrent. ({0})", ex.Message); + } + } + + if (torrentUrl.IsNotNullOrWhiteSpace()) + { + return DownloadFromWebUrl(release, torrentUrl); + } + } + + return null; + } + + private string DownloadFromWebUrl(ReleaseInfo release, string torrentUrl) + { + byte[] torrentFile = null; + + try + { + var request = new HttpRequest(torrentUrl); + request.Headers.Accept = "application/x-bittorrent"; + request.AllowAutoRedirect = false; + + var response = _httpClient.Get(request); + + if (response.StatusCode == HttpStatusCode.MovedPermanently || + response.StatusCode == HttpStatusCode.Found || + response.StatusCode == HttpStatusCode.SeeOther) + { + var locationHeader = response.Headers.GetSingleValue("Location"); + + _logger.Trace("Torrent request is being redirected to: {0}", locationHeader); + + if (locationHeader != null) + { + if (locationHeader.StartsWith("magnet:")) + { + return DownloadFromMagnetUrl(release, locationHeader); + } + + return DownloadFromWebUrl(release, locationHeader); + } + + throw new WebException("Remote website tried to redirect without providing a location."); + } + + torrentFile = response.ResponseData; + + _logger.Debug("Downloading torrent for release '{0}' finished ({1} bytes from {2})", release.Title, torrentFile.Length, torrentUrl); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + _logger.Error(ex, "Downloading torrent file for release '{0}' failed since it no longer exists ({1})", release.Title, torrentUrl); + throw new ReleaseUnavailableException(release, "Downloading torrent failed", ex); + } + + if ((int)ex.Response.StatusCode == 429) + { + _logger.Error("API Grab Limit reached for {0}", torrentUrl); + } + else + { + _logger.Error(ex, "Downloading torrent file for release '{0}' failed ({1})", release.Title, torrentUrl); + } + + throw new ReleaseDownloadException(release, "Downloading torrent failed", ex); + } + catch (WebException ex) + { + _logger.Error(ex, "Downloading torrent file for release '{0}' failed ({1})", release.Title, torrentUrl); + + throw new ReleaseDownloadException(release, "Downloading torrent failed", ex); + } + + var filename = string.Format("{0}.torrent", StringUtil.CleanFileName(release.Title)); + var hash = _torrentFileInfoReader.GetHashFromTorrentFile(torrentFile); + var actualHash = AddFromTorrentFile(release, hash, filename, torrentFile); + + if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) + { + _logger.Debug( + "{0} did not return the expected InfoHash for '{1}', Prowlarr could potentially lose track of the download in progress.", + Definition.Implementation, + release.DownloadUrl); + } + + return actualHash; + } + + private string DownloadFromMagnetUrl(ReleaseInfo release, string magnetUrl) + { + string hash = null; + string actualHash = null; + + try + { + hash = MagnetLink.Parse(magnetUrl).InfoHash.ToHex(); + } + catch (FormatException ex) + { + _logger.Error(ex, "Failed to parse magnetlink for release '{0}': '{1}'", release.Title, magnetUrl); + + return null; + } + + if (hash != null) + { + actualHash = AddFromMagnetLink(release, hash, magnetUrl); + } + + if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) + { + _logger.Debug( + "{0} did not return the expected InfoHash for '{1}', Prowlarr could potentially lose track of the download in progress.", + Definition.Implementation, + release.DownloadUrl); + } + + return actualHash; + } + } +} diff --git a/src/NzbDrone.Core/Download/TorrentFileInfoReader.cs b/src/NzbDrone.Core/Download/TorrentFileInfoReader.cs new file mode 100644 index 000000000..ef944cc1b --- /dev/null +++ b/src/NzbDrone.Core/Download/TorrentFileInfoReader.cs @@ -0,0 +1,17 @@ +using MonoTorrent; + +namespace NzbDrone.Core.Download +{ + public interface ITorrentFileInfoReader + { + string GetHashFromTorrentFile(byte[] fileContents); + } + + public class TorrentFileInfoReader : ITorrentFileInfoReader + { + public string GetHashFromTorrentFile(byte[] fileContents) + { + return Torrent.Load(fileContents).InfoHash.ToHex(); + } + } +} diff --git a/src/NzbDrone.Core/Download/UsenetClientBase.cs b/src/NzbDrone.Core/Download/UsenetClientBase.cs new file mode 100644 index 000000000..98e4eff07 --- /dev/null +++ b/src/NzbDrone.Core/Download/UsenetClientBase.cs @@ -0,0 +1,88 @@ +using System.Net; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Download +{ + public abstract class UsenetClientBase : DownloadClientBase + where TSettings : IProviderConfig, new() + { + protected readonly IHttpClient _httpClient; + private readonly IValidateNzbs _nzbValidationService; + + protected UsenetClientBase(IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IValidateNzbs nzbValidationService, + Logger logger) + : base(configService, diskProvider, logger) + { + _httpClient = httpClient; + _nzbValidationService = nzbValidationService; + } + + public override DownloadProtocol Protocol => DownloadProtocol.Usenet; + + protected abstract string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContents); + protected abstract string AddFromLink(ReleaseInfo release); + + public override string Download(ReleaseInfo release, bool redirect) + { + var url = release.DownloadUrl; + + if (redirect) + { + return AddFromLink(release); + } + + var filename = StringUtil.CleanFileName(release.Title) + ".nzb"; + + byte[] nzbData; + + try + { + var request = new HttpRequest(url); + nzbData = _httpClient.Get(request).ResponseData; + + _logger.Debug("Downloaded nzb for release '{0}' finished ({1} bytes from {2})", release.Title, nzbData.Length, url); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + _logger.Error(ex, "Downloading nzb file for release '{0}' failed since it no longer exists ({1})", release.Title, url); + throw new ReleaseUnavailableException(release, "Downloading nzb failed", ex); + } + + if ((int)ex.Response.StatusCode == 429) + { + _logger.Error("API Grab Limit reached for {0}", url); + } + else + { + _logger.Error(ex, "Downloading nzb for release '{0}' failed ({1})", release.Title, url); + } + + throw new ReleaseDownloadException(release, "Downloading nzb failed", ex); + } + catch (WebException ex) + { + _logger.Error(ex, "Downloading nzb for release '{0}' failed ({1})", release.Title, url); + + throw new ReleaseDownloadException(release, "Downloading nzb failed", ex); + } + + _nzbValidationService.Validate(filename, nzbData); + + _logger.Info("Adding report [{0}] to the queue.", release.Title); + return AddFromNzbFile(release, filename, nzbData); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs new file mode 100644 index 000000000..5ea8cd892 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientStatusCheck.cs @@ -0,0 +1,46 @@ +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Download; +using NzbDrone.Core.Localization; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] + public class DownloadClientStatusCheck : HealthCheckBase + { + private readonly IDownloadClientFactory _providerFactory; + private readonly IDownloadClientStatusService _providerStatusService; + + public DownloadClientStatusCheck(IDownloadClientFactory providerFactory, IDownloadClientStatusService providerStatusService, ILocalizationService localizationService) + : base(localizationService) + { + _providerFactory = providerFactory; + _providerStatusService = providerStatusService; + } + + public override HealthCheck Check() + { + var enabledProviders = _providerFactory.GetAvailableProviders(); + var backOffProviders = enabledProviders.Join(_providerStatusService.GetBlockedProviders(), + i => i.Definition.Id, + s => s.ProviderId, + (i, s) => new { Provider = i, Status = s }) + .ToList(); + + if (backOffProviders.Empty()) + { + return new HealthCheck(GetType()); + } + + if (backOffProviders.Count == enabledProviders.Count) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, _localizationService.GetLocalizedString("DownloadClientStatusCheckAllClientMessage"), "#download_clients_are_unavailable_due_to_failures"); + } + + return new HealthCheck(GetType(), HealthCheckResult.Warning, string.Format(_localizationService.GetLocalizedString("DownloadClientStatusCheckSingleClientMessage"), string.Join(", ", backOffProviders.Select(v => v.Provider.Definition.Name))), "#download_clients_are_unavailable_due_to_failures"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs new file mode 100644 index 000000000..a5f584797 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs @@ -0,0 +1,27 @@ +using Dapper; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedDownloadClientStatus : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedDownloadClientStatus(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.OpenConnection(); + + mapper.Execute(@"DELETE FROM DownloadClientStatus + WHERE Id IN ( + SELECT DownloadClientStatus.Id FROM DownloadClientStatus + LEFT OUTER JOIN DownloadClients + ON DownloadClientStatus.ProviderId = DownloadClients.Id + WHERE DownloadClients.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureApplicationStatusTimes.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureApplicationStatusTimes.cs new file mode 100644 index 000000000..e26e58c9e --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureApplicationStatusTimes.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Applications; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class FixFutureApplicationStatusTimes : FixFutureProviderStatusTimes, IHousekeepingTask + { + public FixFutureApplicationStatusTimes(IApplicationStatusRepository applicationStatusRepository) + : base(applicationStatusRepository) + { + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimes.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimes.cs new file mode 100644 index 000000000..58361e0b7 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureDownloadClientStatusTimes.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Download; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class FixFutureDownloadClientStatusTimes : FixFutureProviderStatusTimes, IHousekeepingTask + { + public FixFutureDownloadClientStatusTimes(IDownloadClientStatusRepository downloadClientStatusRepository) + : base(downloadClientStatusRepository) + { + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index 8b068cf87..f6f0dfb64 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -60,7 +60,7 @@ namespace NzbDrone.Core.IndexerSearch searchSpec.TmdbId = request.tmdbid; searchSpec.TraktId = request.traktid; - return new NewznabResults { Releases = MapReleases(await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec), request.server) }; + return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) }; } private async Task MusicSearch(NewznabRequest request, List indexerIds, bool interactiveSearch) @@ -71,7 +71,7 @@ namespace NzbDrone.Core.IndexerSearch searchSpec.Album = request.album; searchSpec.Label = request.label; - return new NewznabResults { Releases = MapReleases(await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec), request.server) }; + return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) }; } private async Task TvSearch(NewznabRequest request, List indexerIds, bool interactiveSearch) @@ -86,7 +86,7 @@ namespace NzbDrone.Core.IndexerSearch searchSpec.RId = request.rid; searchSpec.TvMazeId = request.tvmazeid; - return new NewznabResults { Releases = MapReleases(await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec), request.server) }; + return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) }; } private async Task BookSearch(NewznabRequest request, List indexerIds, bool interactiveSearch) @@ -96,24 +96,14 @@ namespace NzbDrone.Core.IndexerSearch searchSpec.Author = request.author; searchSpec.Title = request.title; - return new NewznabResults { Releases = MapReleases(await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec), request.server) }; + return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) }; } private async Task BasicSearch(NewznabRequest request, List indexerIds, bool interactiveSearch) { var searchSpec = Get(request, indexerIds, interactiveSearch); - return new NewznabResults { Releases = MapReleases(await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec), request.server) }; - } - - private List MapReleases(List releases, string serverUrl) - { - foreach (var result in releases) - { - result.DownloadUrl = _downloadMappingService.ConvertToProxyLink(new Uri(result.DownloadUrl), serverUrl, result.IndexerId, result.Title).ToString(); - } - - return releases; + return new NewznabResults { Releases = await Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) }; } private TSpec Get(NewznabRequest query, List indexerIds, bool interactiveSearch) diff --git a/src/NzbDrone.Core/Indexers/DownloadService.cs b/src/NzbDrone.Core/Indexers/DownloadService.cs deleted file mode 100644 index 978bf6f01..000000000 --- a/src/NzbDrone.Core/Indexers/DownloadService.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Threading.Tasks; -using NLog; -using NzbDrone.Common.EnsureThat; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; -using NzbDrone.Common.TPL; -using NzbDrone.Core.Exceptions; -using NzbDrone.Core.Indexers.Events; -using NzbDrone.Core.Messaging.Events; - -namespace NzbDrone.Core.Indexers -{ - public interface IDownloadService - { - Task DownloadReport(string link, int indexerId, string source, string title); - void RecordRedirect(string link, int indexerId, string source, string title); - } - - public class DownloadService : IDownloadService - { - private readonly IIndexerFactory _indexerFactory; - private readonly IIndexerStatusService _indexerStatusService; - private readonly IRateLimitService _rateLimitService; - private readonly IEventAggregator _eventAggregator; - private readonly Logger _logger; - - public DownloadService(IIndexerFactory indexerFactory, - IIndexerStatusService indexerStatusService, - IRateLimitService rateLimitService, - IEventAggregator eventAggregator, - Logger logger) - { - _indexerFactory = indexerFactory; - _indexerStatusService = indexerStatusService; - _rateLimitService = rateLimitService; - _eventAggregator = eventAggregator; - _logger = logger; - } - - public async Task DownloadReport(string link, int indexerId, string source, string title) - { - var url = new HttpUri(link); - - // Limit grabs to 2 per second. - if (link.IsNotNullOrWhiteSpace() && !link.StartsWith("magnet:")) - { - await _rateLimitService.WaitAndPulseAsync(url.Host, TimeSpan.FromSeconds(2)); - } - - var indexer = _indexerFactory.GetInstance(_indexerFactory.Get(indexerId)); - var success = false; - var downloadedBytes = Array.Empty(); - - try - { - downloadedBytes = await indexer.Download(url); - _indexerStatusService.RecordSuccess(indexerId); - success = true; - } - catch (ReleaseUnavailableException) - { - _logger.Trace("Release {0} no longer available on indexer.", link); - _eventAggregator.PublishEvent(new IndexerDownloadEvent(indexerId, success, source, title)); - throw; - } - catch (ReleaseDownloadException ex) - { - var http429 = ex.InnerException as TooManyRequestsException; - if (http429 != null) - { - _indexerStatusService.RecordFailure(indexerId, http429.RetryAfter); - } - else - { - _indexerStatusService.RecordFailure(indexerId); - } - - _eventAggregator.PublishEvent(new IndexerDownloadEvent(indexerId, success, source, title)); - throw; - } - - _eventAggregator.PublishEvent(new IndexerDownloadEvent(indexerId, success, source, title)); - return downloadedBytes; - } - - public void RecordRedirect(string link, int indexerId, string source, string title) - { - _eventAggregator.PublishEvent(new IndexerDownloadEvent(indexerId, true, source, title, true)); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index b53b1fa52..4d3721a7d 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -481,10 +481,10 @@ namespace NzbDrone.Core.Indexers return new IndexerResponse(request, response, stopWatch.ElapsedMilliseconds); } - protected HttpResponse ExecuteAuth(HttpRequest request) + protected async Task ExecuteAuth(HttpRequest request) { var stopWatch = Stopwatch.StartNew(); - var response = _httpClient.Execute(request); + var response = await _httpClient.ExecuteAsync(request); stopWatch.Stop(); _eventAggregator.PublishEvent(new IndexerAuthEvent(Definition.Id, !response.HasHttpError, stopWatch.ElapsedMilliseconds)); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index e0e0393e6..28755adf1 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -6,6 +6,8 @@ "AddIndexer": "Add Indexer", "AddingTag": "Adding tag", "AddNewIndexer": "Add New Indexer", + "AddToDownloadClient": "Add release to download client", + "AddedToDownloadClient": "Release added to client", "Age": "Age", "All": "All", "AllIndexersHiddenDueToFilter": "All indexers are hidden due to applied filter.", @@ -45,6 +47,7 @@ "CertificateValidationHelpText": "Change how strict HTTPS certification validation is", "ChangeHasNotBeenSavedYet": "Change has not been saved yet", "Clear": "Clear", + "ClientPriority": "Client Priority", "CloneIndexer": "Clone Indexer", "Close": "Close", "CloseCurrentModal": "Close Current Modal", @@ -75,6 +78,15 @@ "DevelopmentSettings": "Development Settings", "Disabled": "Disabled", "Docker": "Docker", + "DownloadClient": "Download Client", + "DownloadClientCheckNoneAvailableMessage": "No download client is available", + "DownloadClientCheckUnableToCommunicateMessage": "Unable to communicate with {0}.", + "DownloadClients": "Download Clients", + "DownloadClientSettings": "Download Client Settings", + "DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings", + "DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures", + "DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}", + "DownloadClientUnavailable": "Download client is unavailable", "Downloading": "Downloading", "EditIndexer": "Edit Indexer", "Enable": "Enable", @@ -110,6 +122,7 @@ "Fixed": "Fixed", "FocusSearchBox": "Focus Search Box", "Folder": "Folder", + "ForMoreInformationOnTheIndividualDownloadClients": "For more information on the individual download clients, click on the info buttons.", "General": "General", "GeneralSettings": "General Settings", "GeneralSettingsSummary": "Port, SSL, username/password, proxy, analytics and updates", @@ -206,7 +219,9 @@ "PortNumber": "Port Number", "PreferredSize": "Preferred Size", "Priority": "Priority", + "PriorityHelpText": "Prioritize multiple Download Clients. Round-Robin is used for clients with the same priority.", "Protocol": "Protocol", + "ProwlarrSupportsAnyDownloadClient": "Prowlarr supports any of the download clients listed below.", "ProwlarrSupportsAnyIndexer": "Prowlarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.", "Proxy": "Proxy", "ProxyBypassFilterHelpText": "Use ',' as a separator, and '*.' as a wildcard for subdomains", @@ -302,6 +317,7 @@ "Test": "Test", "TestAll": "Test All", "TestAllApps": "Test All Apps", + "TestAllClients": "Test All Clients", "Time": "Time", "Title": "Title", "Today": "Today", diff --git a/src/NzbDrone.Core/Parser/StringUtil.cs b/src/NzbDrone.Core/Parser/StringUtil.cs index ee714fc4c..7d8fa25b7 100644 --- a/src/NzbDrone.Core/Parser/StringUtil.cs +++ b/src/NzbDrone.Core/Parser/StringUtil.cs @@ -12,6 +12,26 @@ namespace NzbDrone.Core.Parser { public static class StringUtil { + public static string CleanFileName(string name, bool replace = true) + { + string result = name; + string[] badCharacters = { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" }; + string[] goodCharacters = { "+", "+", "", "", "!", "-", "-", "", "" }; + + // Replace a colon followed by a space with space dash space for a better appearance + if (replace) + { + result = result.Replace(": ", " - "); + } + + for (int i = 0; i < badCharacters.Length; i++) + { + result = result.Replace(badCharacters[i], replace ? goodCharacters[i] : string.Empty); + } + + return result.TrimStart(' ', '.').TrimEnd(' '); + } + /* public static string StripNonAlphaNumeric(this string str, string replacement = "") => StripRegex(str, "[^a-zA-Z0-9 -]", replacement); diff --git a/src/Prowlarr.Api.V1/DownloadClient/DownloadClientController.cs b/src/Prowlarr.Api.V1/DownloadClient/DownloadClientController.cs new file mode 100644 index 000000000..c2e927924 --- /dev/null +++ b/src/Prowlarr.Api.V1/DownloadClient/DownloadClientController.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Download; +using Prowlarr.Http; + +namespace Prowlarr.Api.V1.DownloadClient +{ + [V1ApiController] + public class DownloadClientController : ProviderControllerBase + { + public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); + + public DownloadClientController(IDownloadClientFactory downloadClientFactory) + : base(downloadClientFactory, "downloadclient", ResourceMapper) + { + } + + protected override void Validate(DownloadClientDefinition definition, bool includeWarnings) + { + if (!definition.Enable) + { + return; + } + + base.Validate(definition, includeWarnings); + } + } +} diff --git a/src/Prowlarr.Api.V1/DownloadClient/DownloadClientResource.cs b/src/Prowlarr.Api.V1/DownloadClient/DownloadClientResource.cs new file mode 100644 index 000000000..63de27da2 --- /dev/null +++ b/src/Prowlarr.Api.V1/DownloadClient/DownloadClientResource.cs @@ -0,0 +1,47 @@ +using NzbDrone.Core.Download; +using NzbDrone.Core.Indexers; + +namespace Prowlarr.Api.V1.DownloadClient +{ + public class DownloadClientResource : ProviderResource + { + public bool Enable { get; set; } + public DownloadProtocol Protocol { get; set; } + public int Priority { get; set; } + } + + public class DownloadClientResourceMapper : ProviderResourceMapper + { + public override DownloadClientResource ToResource(DownloadClientDefinition definition) + { + if (definition == null) + { + return null; + } + + var resource = base.ToResource(definition); + + resource.Enable = definition.Enable; + resource.Protocol = definition.Protocol; + resource.Priority = definition.Priority; + + return resource; + } + + public override DownloadClientDefinition ToModel(DownloadClientResource resource) + { + if (resource == null) + { + return null; + } + + var definition = base.ToModel(resource); + + definition.Enable = resource.Enable; + definition.Protocol = resource.Protocol; + definition.Priority = resource.Priority; + + return definition; + } + } +} diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerController.cs b/src/Prowlarr.Api.V1/Indexers/IndexerController.cs index 636a829cf..521a37109 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerController.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerController.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Download; using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.Parser; @@ -73,6 +74,12 @@ namespace Prowlarr.Api.V1.Indexers case "book": case "movie": var results = await _nzbSearchService.Search(request, new List { indexer.Id }, false); + + foreach (var result in results.Releases) + { + result.DownloadUrl = _downloadMappingService.ConvertToProxyLink(new Uri(result.DownloadUrl), request.server, indexer.Id, result.Title).ToString(); + } + return Content(results.ToXml(indexerInstance.Protocol), "application/rss+xml"); default: throw new BadRequestException("Function Not Available"); diff --git a/src/Prowlarr.Api.V1/Search/SearchController.cs b/src/Prowlarr.Api.V1/Search/SearchController.cs index 6a561ee2f..8f757b3ce 100644 --- a/src/Prowlarr.Api.V1/Search/SearchController.cs +++ b/src/Prowlarr.Api.V1/Search/SearchController.cs @@ -3,27 +3,73 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; +using FluentValidation; using Microsoft.AspNetCore.Mvc; using NLog; +using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Download; using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; using Prowlarr.Http; using Prowlarr.Http.Extensions; +using Prowlarr.Http.REST; namespace Prowlarr.Api.V1.Search { [V1ApiController] - public class SearchController : Controller + public class SearchController : RestController { private readonly ISearchForNzb _nzbSearhService; + private readonly IDownloadService _downloadService; + private readonly IIndexerFactory _indexerFactory; + private readonly IDownloadMappingService _downloadMappingService; private readonly Logger _logger; - public SearchController(ISearchForNzb nzbSearhService, Logger logger) + private readonly ICached _remoteReleaseCache; + + public SearchController(ISearchForNzb nzbSearhService, IDownloadService downloadService, IIndexerFactory indexerFactory, IDownloadMappingService downloadMappingService, ICacheManager cacheManager, Logger logger) { _nzbSearhService = nzbSearhService; + _downloadService = downloadService; + _indexerFactory = indexerFactory; + _downloadMappingService = downloadMappingService; _logger = logger; + + PostValidator.RuleFor(s => s.IndexerId).ValidId(); + PostValidator.RuleFor(s => s.Guid).NotEmpty(); + + _remoteReleaseCache = cacheManager.GetCache(GetType(), "remoteReleases"); + } + + public override SearchResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + + [HttpPost] + public ActionResult Create(SearchResource release) + { + ValidateResource(release); + + var releaseInfo = _remoteReleaseCache.Find(GetCacheKey(release)); + + var indexerDef = _indexerFactory.Get(release.IndexerId); + + try + { + _downloadService.SendReportToClient(releaseInfo, indexerDef.Redirect); + } + catch (ReleaseDownloadException ex) + { + _logger.Error(ex, "Getting release from indexer failed"); + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed"); + } + + return Ok(release); } [HttpGet] @@ -52,7 +98,7 @@ namespace Prowlarr.Api.V1.Search var result = await _nzbSearhService.Search(request, indexerIds, true); var decisions = result.Releases; - return MapDecisions(decisions); + return MapDecisions(decisions, Request.GetServerUrl()); } catch (SearchFailedException ex) { @@ -66,7 +112,7 @@ namespace Prowlarr.Api.V1.Search return new List(); } - protected virtual List MapDecisions(IEnumerable releases) + protected virtual List MapDecisions(IEnumerable releases, string serverUrl) { var result = new List(); @@ -74,10 +120,18 @@ namespace Prowlarr.Api.V1.Search { var release = downloadDecision.ToResource(); + _remoteReleaseCache.Set(GetCacheKey(release), downloadDecision, TimeSpan.FromMinutes(30)); + release.DownloadUrl = _downloadMappingService.ConvertToProxyLink(new Uri(release.DownloadUrl), serverUrl, release.IndexerId, release.Title).ToString(); + result.Add(release); } return result; } + + private string GetCacheKey(SearchResource resource) + { + return string.Concat(resource.IndexerId, "_", resource.Guid); + } } }