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 && + + + {translate('Custom')} + + + + + {translate('Presets')} + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + + {translate('MoreInfo')} + + + + + ); + } +} + +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 ( + + ); + }) + } + + + + } + + + + {translate('Close')} + + + + ); + } +} + +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 ? + + {translate('Enabled')} + : + + {translate('Disabled')} + + } + + { + priority > 1 && + + {translate('PrioritySettings', [priority])} + + } + + + + + + + ); + } +} + +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('Delete')} + + } + + + {translate('Test')} + + + + {translate('Cancel')} + + + + {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