mirror of
https://github.com/Prowlarr/Prowlarr
synced 2026-04-23 21:31:00 +02:00
New: Download Clients for Manual Grabs
This commit is contained in:
parent
fd27018caa
commit
881313ef2b
209 changed files with 12229 additions and 127 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/downloadclients"
|
||||
component={DownloadClientSettingsConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/connect"
|
||||
component={NotificationSettings}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ const links = [
|
|||
title: translate('Apps'),
|
||||
to: '/settings/applications'
|
||||
},
|
||||
{
|
||||
title: translate('DownloadClients'),
|
||||
to: '/settings/downloadclients'
|
||||
},
|
||||
{
|
||||
title: translate('Connect'),
|
||||
to: '/settings/connect'
|
||||
|
|
|
|||
|
|
@ -266,7 +266,6 @@ IndexerIndexRow.propTypes = {
|
|||
added: PropTypes.string.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isSearchingMovie: PropTypes.bool.isRequired,
|
||||
isMovieEditorActive: PropTypes.bool.isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
onSelectedChange: PropTypes.func.isRequired,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { Component } from 'react';
|
|||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
|
|
@ -15,8 +16,59 @@ import Peers from './Peers';
|
|||
import ProtocolLabel from './ProtocolLabel';
|
||||
import styles from './SearchIndexRow.css';
|
||||
|
||||
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
|
||||
if (isGrabbing) {
|
||||
return icons.SPINNER;
|
||||
} else if (isGrabbed) {
|
||||
return icons.DOWNLOADING;
|
||||
} else if (grabError) {
|
||||
return icons.DOWNLOADING;
|
||||
}
|
||||
|
||||
return icons.DOWNLOAD;
|
||||
}
|
||||
|
||||
function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
|
||||
if (isGrabbing) {
|
||||
return '';
|
||||
} else if (isGrabbed) {
|
||||
return translate('AddedToDownloadClient');
|
||||
} else if (grabError) {
|
||||
return grabError;
|
||||
}
|
||||
|
||||
return translate('AddToDownloadClient');
|
||||
}
|
||||
|
||||
class SearchIndexRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isConfirmGrabModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onGrabPress = () => {
|
||||
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]}
|
||||
>
|
||||
<IconButton
|
||||
className={styles.downloadLink}
|
||||
name={icons.DOWNLOAD}
|
||||
title={'Grab'}
|
||||
to={downloadUrl}
|
||||
<SpinnerIconButton
|
||||
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
|
||||
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
|
||||
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
|
||||
isDisabled={isGrabbed}
|
||||
isSpinning={isGrabbing}
|
||||
onPress={this.onGrabPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
|
|
@ -259,8 +316,17 @@ SearchIndexRow.propTypes = {
|
|||
leechers: PropTypes.number,
|
||||
indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onGrabPress: PropTypes.func.isRequired,
|
||||
isGrabbing: PropTypes.bool.isRequired,
|
||||
isGrabbed: PropTypes.bool.isRequired,
|
||||
grabError: PropTypes.string,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
SearchIndexRow.defaultProps = {
|
||||
isGrabbing: false,
|
||||
isGrabbed: false
|
||||
};
|
||||
|
||||
export default SearchIndexRow;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ class SearchIndexTable extends Component {
|
|||
items,
|
||||
columns,
|
||||
longDateFormat,
|
||||
timeFormat
|
||||
timeFormat,
|
||||
onGrabPress
|
||||
} = this.props;
|
||||
|
||||
const release = items[rowIndex];
|
||||
|
|
@ -65,6 +66,7 @@ class SearchIndexTable extends Component {
|
|||
guid={release.guid}
|
||||
longDateFormat={longDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
onGrabPress={onGrabPress}
|
||||
/>
|
||||
</VirtualTableRow>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<PageContent title={translate('DownloadClientSettings')}>
|
||||
<SettingsToolbarConnector
|
||||
isSaving={isSaving}
|
||||
hasPendingChanges={hasPendingChanges}
|
||||
additionalButtons={
|
||||
<Fragment>
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('TestAllClients')}
|
||||
iconName={icons.TEST}
|
||||
isSpinning={isTestingAll}
|
||||
onPress={dispatchTestAllDownloadClients}
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
onSavePress={this.onSavePress}
|
||||
/>
|
||||
|
||||
<PageContentBody>
|
||||
<DownloadClientsConnector />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DownloadClientSettings.propTypes = {
|
||||
isTestingAll: PropTypes.bool.isRequired,
|
||||
dispatchTestAllDownloadClients: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DownloadClientSettings;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={styles.downloadClient}
|
||||
>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={this.onDownloadClientSelect}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.name}>
|
||||
{implementationName}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{
|
||||
hasPresets &&
|
||||
<span>
|
||||
<Button
|
||||
size={sizes.SMALL}
|
||||
onPress={this.onDownloadClientSelect}
|
||||
>
|
||||
{translate('Custom')}
|
||||
</Button>
|
||||
|
||||
<Menu className={styles.presetsMenu}>
|
||||
<Button
|
||||
className={styles.presetsMenuButton}
|
||||
size={sizes.SMALL}
|
||||
>
|
||||
{translate('Presets')}
|
||||
</Button>
|
||||
|
||||
<MenuContent>
|
||||
{
|
||||
presets.map((preset) => {
|
||||
return (
|
||||
<AddDownloadClientPresetMenuItem
|
||||
key={preset.name}
|
||||
name={preset.name}
|
||||
implementation={implementation}
|
||||
onPress={onDownloadClientSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</span>
|
||||
}
|
||||
|
||||
<Button
|
||||
to={infoLink}
|
||||
size={sizes.SMALL}
|
||||
>
|
||||
{translate('MoreInfo')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AddDownloadClientModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddDownloadClientModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddDownloadClientModal;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.downloadClients {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('AddDownloadClient')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
isSchemaFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isSchemaFetching && !!schemaError &&
|
||||
<div>
|
||||
{translate('UnableToAddANewDownloadClientPleaseTryAgain')}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
isSchemaPopulated && !schemaError &&
|
||||
<div>
|
||||
|
||||
<Alert kind={kinds.INFO}>
|
||||
<div>
|
||||
{translate('ProwlarrSupportsAnyDownloadClient')}
|
||||
</div>
|
||||
<div>
|
||||
{translate('ForMoreInformationOnTheIndividualDownloadClients')}
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<FieldSet legend={translate('Usenet')}>
|
||||
<div className={styles.downloadClients}>
|
||||
{
|
||||
usenetDownloadClients.map((downloadClient) => {
|
||||
return (
|
||||
<AddDownloadClientItem
|
||||
key={downloadClient.implementation}
|
||||
implementation={downloadClient.implementation}
|
||||
{...downloadClient}
|
||||
onDownloadClientSelect={onDownloadClientSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Torrents')}>
|
||||
<div className={styles.downloadClients}>
|
||||
{
|
||||
torrentDownloadClients.map((downloadClient) => {
|
||||
return (
|
||||
<AddDownloadClientItem
|
||||
key={downloadClient.implementation}
|
||||
implementation={downloadClient.implementation}
|
||||
{...downloadClient}
|
||||
onDownloadClientSelect={onDownloadClientSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</FieldSet>
|
||||
</div>
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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 (
|
||||
<AddDownloadClientModalContent
|
||||
{...this.props}
|
||||
onDownloadClientSelect={this.onDownloadClientSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddDownloadClientModalContentConnector.propTypes = {
|
||||
fetchDownloadClientSchema: PropTypes.func.isRequired,
|
||||
selectDownloadClientSchema: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector);
|
||||
|
|
@ -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 (
|
||||
<MenuItem
|
||||
{...otherProps}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddDownloadClientPresetMenuItem.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
implementation: PropTypes.string.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddDownloadClientPresetMenuItem;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Card
|
||||
className={styles.downloadClient}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditDownloadClientPress}
|
||||
>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className={styles.enabled}>
|
||||
{
|
||||
enable ?
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
{translate('Enabled')}
|
||||
</Label> :
|
||||
<Label
|
||||
kind={kinds.DISABLED}
|
||||
outline={true}
|
||||
>
|
||||
{translate('Disabled')}
|
||||
</Label>
|
||||
}
|
||||
|
||||
{
|
||||
priority > 1 &&
|
||||
<Label
|
||||
kind={kinds.DISABLED}
|
||||
outline={true}
|
||||
>
|
||||
{translate('PrioritySettings', [priority])}
|
||||
</Label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<EditDownloadClientModalConnector
|
||||
id={id}
|
||||
isOpen={this.state.isEditDownloadClientModalOpen}
|
||||
onModalClose={this.onEditDownloadClientModalClose}
|
||||
onDeleteDownloadClientPress={this.onDeleteDownloadClientPress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isDeleteDownloadClientModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteDownloadClient')}
|
||||
message={translate('DeleteDownloadClientMessageText', [name])}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={this.onConfirmDeleteDownloadClient}
|
||||
onCancel={this.onDeleteDownloadClientModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<FieldSet legend={translate('DownloadClients')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('UnableToLoadDownloadClients')}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.downloadClients}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<DownloadClient
|
||||
key={item.id}
|
||||
{...item}
|
||||
onConfirmDeleteDownloadClient={onConfirmDeleteDownloadClient}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<Card
|
||||
className={styles.addDownloadClient}
|
||||
onPress={this.onAddDownloadClientPress}
|
||||
>
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.ADD}
|
||||
size={45}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<AddDownloadClientModal
|
||||
isOpen={isAddDownloadClientModalOpen}
|
||||
onModalClose={this.onAddDownloadClientModalClose}
|
||||
/>
|
||||
|
||||
<EditDownloadClientModalConnector
|
||||
isOpen={isEditDownloadClientModalOpen}
|
||||
onModalClose={this.onEditDownloadClientModalClose}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DownloadClients.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onConfirmDeleteDownloadClient: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DownloadClients;
|
||||
|
|
@ -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 (
|
||||
<DownloadClients
|
||||
{...this.props}
|
||||
onConfirmDeleteDownloadClient={this.onConfirmDeleteDownloadClient}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DownloadClientsConnector.propTypes = {
|
||||
fetchDownloadClients: PropTypes.func.isRequired,
|
||||
deleteDownloadClient: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector);
|
||||
|
|
@ -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 (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditDownloadClientModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
EditDownloadClientModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditDownloadClientModal;
|
||||
|
|
@ -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 (
|
||||
<EditDownloadClientModal
|
||||
{...otherProps}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditDownloadClientModalConnector.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
dispatchClearPendingChanges: PropTypes.func.isRequired,
|
||||
dispatchCancelTestDownloadClient: PropTypes.func.isRequired,
|
||||
dispatchCancelSaveDownloadClient: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, createMapDispatchToProps)(EditDownloadClientModalConnector);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{`${id ? translate('Edit') : translate('Add')} ${translate('DownloadClient')} - ${implementationName}`}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<div>
|
||||
{translate('UnableToAddANewDownloadClientPleaseTryAgain')}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !error &&
|
||||
<Form {...otherProps}>
|
||||
{
|
||||
!!message &&
|
||||
<Alert
|
||||
className={styles.message}
|
||||
kind={message.value.type}
|
||||
>
|
||||
{message.value.message}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Name')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="name"
|
||||
{...name}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Enable')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enable"
|
||||
{...enable}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
fields.map((field) => {
|
||||
return (
|
||||
<ProviderFieldFormGroup
|
||||
key={field.name}
|
||||
advancedSettings={advancedSettings}
|
||||
provider="downloadClient"
|
||||
providerData={item}
|
||||
{...field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('ClientPriority')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="priority"
|
||||
helpText={translate('PriorityHelpText')}
|
||||
min={1}
|
||||
max={50}
|
||||
{...priority}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
</Form>
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{
|
||||
id &&
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteDownloadClientPress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
}
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isTesting}
|
||||
error={saveError}
|
||||
onPress={onTestPress}
|
||||
>
|
||||
{translate('Test')}
|
||||
</SpinnerErrorButton>
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -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 (
|
||||
<EditDownloadClientModalContent
|
||||
{...this.props}
|
||||
onSavePress={this.onSavePress}
|
||||
onTestPress={this.onTestPress}
|
||||
onInputChange={this.onInputChange}
|
||||
onFieldChange={this.onFieldChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -25,6 +25,17 @@ function Settings() {
|
|||
Applications and settings to configure how prowlarr interacts with your PVR programs
|
||||
</div>
|
||||
|
||||
<Link
|
||||
className={styles.link}
|
||||
to="/settings/downloadclients"
|
||||
>
|
||||
{translate('DownloadClients')}
|
||||
</Link>
|
||||
|
||||
<div className={styles.summary}>
|
||||
{translate('DownloadClientsSettingsSummary')}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
className={styles.link}
|
||||
to="/settings/connect"
|
||||
|
|
|
|||
117
frontend/src/Store/Actions/Settings/downloadClients.js
Normal file
117
frontend/src/Store/Actions/Settings/downloadClients.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
|
||||
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
|
||||
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
|
||||
import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler';
|
||||
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
|
||||
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
|
||||
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
const section = 'settings.downloadClients';
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_DOWNLOAD_CLIENTS = 'settings/downloadClients/fetchDownloadClients';
|
||||
export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/fetchDownloadClientSchema';
|
||||
export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/selectDownloadClientSchema';
|
||||
export const SET_DOWNLOAD_CLIENT_VALUE = 'settings/downloadClients/setDownloadClientValue';
|
||||
export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'settings/downloadClients/setDownloadClientFieldValue';
|
||||
export const SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/saveDownloadClient';
|
||||
export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelSaveDownloadClient';
|
||||
export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadClient';
|
||||
export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient';
|
||||
export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
|
||||
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchDownloadClients = createThunk(FETCH_DOWNLOAD_CLIENTS);
|
||||
export const fetchDownloadClientSchema = createThunk(FETCH_DOWNLOAD_CLIENT_SCHEMA);
|
||||
export const selectDownloadClientSchema = createAction(SELECT_DOWNLOAD_CLIENT_SCHEMA);
|
||||
|
||||
export const saveDownloadClient = createThunk(SAVE_DOWNLOAD_CLIENT);
|
||||
export const cancelSaveDownloadClient = createThunk(CANCEL_SAVE_DOWNLOAD_CLIENT);
|
||||
export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
|
||||
export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
|
||||
export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
|
||||
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
|
||||
|
||||
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
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 @@ public static void Map()
|
|||
.Ignore(i => i.Capabilities)
|
||||
.Ignore(d => d.Tags);
|
||||
|
||||
Mapper.Entity<DownloadClientDefinition>("DownloadClients").RegisterModel()
|
||||
.Ignore(x => x.ImplementationName)
|
||||
.Ignore(i => i.Protocol)
|
||||
.Ignore(d => d.Tags);
|
||||
|
||||
Mapper.Entity<NotificationDefinition>("Notifications").RegisterModel()
|
||||
.Ignore(x => x.ImplementationName)
|
||||
.Ignore(i => i.SupportsOnHealthIssue);
|
||||
|
|
@ -70,6 +76,8 @@ public static void Map()
|
|||
|
||||
Mapper.Entity<IndexerStatus>("IndexerStatus").RegisterModel();
|
||||
|
||||
Mapper.Entity<DownloadClientStatus>("DownloadClientStatus").RegisterModel();
|
||||
|
||||
Mapper.Entity<ApplicationStatus>("ApplicationStatus").RegisterModel();
|
||||
|
||||
Mapper.Entity<CustomFilter>("CustomFilters").RegisterModel();
|
||||
|
|
|
|||
196
src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs
Normal file
196
src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs
Normal file
|
|
@ -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<DelugeSettings>
|
||||
{
|
||||
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<ValidationFailure> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs
Normal file
8
src/NzbDrone.Core/Download/Clients/Deluge/DelugeError.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
{
|
||||
public class DelugeError
|
||||
{
|
||||
public string Message { get; set; }
|
||||
public int Code { get; set; }
|
||||
}
|
||||
}
|
||||
13
src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs
Normal file
13
src/NzbDrone.Core/Download/Clients/Deluge/DelugeException.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/NzbDrone.Core/Download/Clients/Deluge/DelugeLabel.cs
Normal file
21
src/NzbDrone.Core/Download/Clients/Deluge/DelugeLabel.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
{
|
||||
public enum DelugePriority
|
||||
{
|
||||
Last = 0,
|
||||
First = 1
|
||||
}
|
||||
}
|
||||
371
src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs
Normal file
371
src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs
Normal file
|
|
@ -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<string, object> 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<Dictionary<string, string>> _authCookieCache;
|
||||
|
||||
public DelugeProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
|
||||
_authCookieCache = cacheManager.GetCache<Dictionary<string, string>>(GetType(), "authCookies");
|
||||
}
|
||||
|
||||
public string GetVersion(DelugeSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = ProcessRequest<string>(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<string>(settings, "daemon.get_version");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, object> GetConfig(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<Dictionary<string, object>>(settings, "core.get_config");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public DelugeTorrent[] GetTorrents(DelugeSettings settings)
|
||||
{
|
||||
var filter = new Dictionary<string, object>();
|
||||
|
||||
// TODO: get_torrents_status returns the files as well, which starts to cause deluge timeouts when you get enough season packs.
|
||||
//var response = ProcessRequest<Dictionary<String, DelugeTorrent>>(settings, "core.get_torrents_status", filter, new String[0]);
|
||||
var response = ProcessRequest<DelugeUpdateUIResult>(settings, "web.update_ui", RequiredProperties, filter);
|
||||
|
||||
return GetTorrents(response);
|
||||
}
|
||||
|
||||
public DelugeTorrent[] GetTorrentsByLabel(string label, DelugeSettings settings)
|
||||
{
|
||||
var filter = new Dictionary<string, object>();
|
||||
filter.Add("label", label);
|
||||
|
||||
//var response = ProcessRequest<Dictionary<String, DelugeTorrent>>(settings, "core.get_torrents_status", filter, new String[0]);
|
||||
var response = ProcessRequest<DelugeUpdateUIResult>(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<string>(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<string>(settings, "core.add_torrent_file", filename, fileContent, options);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public bool RemoveTorrent(string hash, bool removeData, DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<bool>(settings, "core.remove_torrent", hash, removeData);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public void MoveTorrentToTopInQueue(string hash, DelugeSettings settings)
|
||||
{
|
||||
ProcessRequest<object>(settings, "core.queue_top", (object)new string[] { hash });
|
||||
}
|
||||
|
||||
public string[] GetAvailablePlugins(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "core.get_available_plugins");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public string[] GetEnabledPlugins(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "core.get_enabled_plugins");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public string[] GetAvailableLabels(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<string[]>(settings, "label.get_labels");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public DelugeLabel GetLabelOptions(DelugeSettings settings)
|
||||
{
|
||||
var response = ProcessRequest<DelugeLabel>(settings, "label.get_options", settings.Category);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public void SetTorrentConfiguration(string hash, string key, object value, DelugeSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add(key, value);
|
||||
|
||||
ProcessRequest<object>(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<string, object>();
|
||||
|
||||
if (seedConfiguration.Ratio != null)
|
||||
{
|
||||
ratioArguments.Add("stop_ratio", seedConfiguration.Ratio.Value);
|
||||
ratioArguments.Add("stop_at_ratio", 1);
|
||||
}
|
||||
|
||||
ProcessRequest<object>(settings, "core.set_torrent_options", new[] { hash }, ratioArguments);
|
||||
}
|
||||
|
||||
public void AddLabel(string label, DelugeSettings settings)
|
||||
{
|
||||
ProcessRequest<object>(settings, "label.add", label);
|
||||
}
|
||||
|
||||
public void SetTorrentLabel(string hash, string label, DelugeSettings settings)
|
||||
{
|
||||
ProcessRequest<object>(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<TResult>(DelugeSettings settings, string method, params object[] arguments)
|
||||
{
|
||||
var requestBuilder = BuildRequest(settings);
|
||||
|
||||
var response = ExecuteRequest<TResult>(requestBuilder, method, arguments);
|
||||
|
||||
if (response.Error != null)
|
||||
{
|
||||
var error = response.Error.ToObject<DelugeError>();
|
||||
if (error.Code == 1 || error.Code == 2)
|
||||
{
|
||||
AuthenticateClient(requestBuilder, settings, true);
|
||||
|
||||
response = ExecuteRequest<TResult>(requestBuilder, method, arguments);
|
||||
|
||||
if (response.Error == null)
|
||||
{
|
||||
return response.Result;
|
||||
}
|
||||
|
||||
error = response.Error.ToObject<DelugeError>();
|
||||
|
||||
throw new DownloadClientAuthenticationException(error.Message);
|
||||
}
|
||||
|
||||
throw new DelugeException(error.Message, error.Code);
|
||||
}
|
||||
|
||||
return response.Result;
|
||||
}
|
||||
|
||||
private JsonRpcResponse<TResult> ExecuteRequest<TResult>(JsonRpcRequestBuilder requestBuilder, string method, params object[] arguments)
|
||||
{
|
||||
var request = requestBuilder.Call(method, arguments).Build();
|
||||
|
||||
HttpResponse response;
|
||||
try
|
||||
{
|
||||
response = _httpClient.Execute(request);
|
||||
|
||||
return Json.Deserialize<JsonRpcResponse<TResult>>(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<TResult>()
|
||||
{
|
||||
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<TResult>(JsonRpcResponse<TResult> response)
|
||||
{
|
||||
if (response.Error != null)
|
||||
{
|
||||
var error = response.Error.ToObject<DelugeError>();
|
||||
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<JsonRpcResponse<bool>>(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<bool>(requestBuilder, "web.connected");
|
||||
VerifyResponse(resultConnected);
|
||||
|
||||
if (resultConnected.Result)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resultHosts = ExecuteRequest<List<object[]>>(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<object>(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<DelugeTorrent>();
|
||||
}
|
||||
|
||||
return result.Torrents.Values.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs
Normal file
60
src/NzbDrone.Core/Download/Clients/Deluge/DelugeSettings.cs
Normal file
|
|
@ -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<DelugeSettings>
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs
Normal file
55
src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.Download.Clients.Deluge
|
||||
{
|
||||
public class DelugeUpdateUIResult
|
||||
{
|
||||
public Dictionary<string, object> Stats { get; set; }
|
||||
public bool Connected { get; set; }
|
||||
public Dictionary<string, DelugeTorrent> Torrents { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace NzbDrone.Core.Download.Clients.DownloadStation
|
||||
{
|
||||
public enum DiskStationApi
|
||||
{
|
||||
Info,
|
||||
Auth,
|
||||
DownloadStationInfo,
|
||||
DownloadStationTask,
|
||||
FileStationList,
|
||||
DSMInfo,
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] { '/', '\\' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DownloadStationSettings>
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// /// Possible values are: BT, NZB, http, ftp, eMule and https
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "status_extra")]
|
||||
public Dictionary<string, string> 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace NzbDrone.Core.Download.Clients.DownloadStation
|
||||
{
|
||||
public class DownloadStationTaskAdditional
|
||||
{
|
||||
public Dictionary<string, string> Detail { get; set; }
|
||||
|
||||
public Dictionary<string, string> Transfer { get; set; }
|
||||
|
||||
[JsonProperty("File")]
|
||||
public List<DownloadStationTaskFile> Files { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DSMInfoResponse>(requestBuilder, "get serial number", settings);
|
||||
|
||||
return response.Data.SerialNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DiskStationApiInfo> _infoCache;
|
||||
private readonly ICached<string> _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<DiskStationApiInfo>(typeof(DiskStationProxyBase), "apiInfo");
|
||||
_sessionCache = cacheManager.GetCache<string>(typeof(DiskStationProxyBase), "sessions");
|
||||
_apiType = apiType;
|
||||
_apiName = apiName;
|
||||
}
|
||||
|
||||
private string GenerateSessionCacheKey(DownloadStationSettings settings)
|
||||
{
|
||||
return $"{settings.Username}@{settings.Host}:{settings.Port}";
|
||||
}
|
||||
|
||||
protected DiskStationResponse<T> ProcessRequest<T>(HttpRequestBuilder requestBuilder,
|
||||
string operation,
|
||||
DownloadStationSettings settings)
|
||||
where T : new()
|
||||
{
|
||||
return ProcessRequest<T>(requestBuilder, operation, _apiType, settings);
|
||||
}
|
||||
|
||||
private DiskStationResponse<T> ProcessRequest<T>(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<DiskStationResponse<T>>(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<DiskStationAuthResponse>(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<string, DiskStationApi>()
|
||||
{
|
||||
{ "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<DiskStationApiInfoResponse>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, object> 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<string, object> GetConfig(DownloadStationSettings settings)
|
||||
{
|
||||
var requestBuilder = BuildRequest(settings, "getConfig", 1);
|
||||
|
||||
var response = ProcessRequest<Dictionary<string, object>>(requestBuilder, "get config", settings);
|
||||
|
||||
return response.Data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DownloadStationTask> 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<object>(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<object>(requestBuilder, $"add task from url {url}", settings);
|
||||
}
|
||||
|
||||
public IEnumerable<DownloadStationTask> GetTasks(DownloadStationSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBuilder = BuildRequest(settings, "list", 1);
|
||||
requestBuilder.AddQueryParam("additional", "detail,transfer");
|
||||
|
||||
var response = ProcessRequest<DownloadStationTaskInfoResponse>(requestBuilder, "get tasks", settings);
|
||||
|
||||
return response.Data.Tasks;
|
||||
}
|
||||
catch (DownloadClientException e)
|
||||
{
|
||||
_logger.Error(e);
|
||||
return new List<DownloadStationTask>();
|
||||
}
|
||||
}
|
||||
|
||||
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<object>(requestBuilder, $"remove item {downloadId}", settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FileStationListResponse>(requestBuilder, $"get info of {path}", settings);
|
||||
|
||||
return response.Data.Files.First();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
|
||||
{
|
||||
public class DSMInfoResponse
|
||||
{
|
||||
[JsonProperty("serial")]
|
||||
public string SerialNumber { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
|
||||
{
|
||||
public class DiskStationAuthResponse
|
||||
{
|
||||
public string SId { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
|
||||
{
|
||||
public class DiskStationError
|
||||
{
|
||||
private static readonly Dictionary<int, string> CommonMessages;
|
||||
private static readonly Dictionary<int, string> AuthMessages;
|
||||
private static readonly Dictionary<int, string> DownloadStationTaskMessages;
|
||||
private static readonly Dictionary<int, string> FileStationMessages;
|
||||
|
||||
static DiskStationError()
|
||||
{
|
||||
CommonMessages = new Dictionary<int, string>
|
||||
{
|
||||
{ 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<int, string>
|
||||
{
|
||||
{ 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<int, string>
|
||||
{
|
||||
{ 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<int, string>
|
||||
{
|
||||
{ 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
|
||||
{
|
||||
public class DiskStationApiInfoResponse : Dictionary<string, DiskStationApiInfo>
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
|
||||
{
|
||||
public class DiskStationResponse<T>
|
||||
where T : new()
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public DiskStationError Error { get; set; }
|
||||
|
||||
public T Data { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DownloadStationTask> Tasks { get; set; }
|
||||
public int Total { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, object> Additional { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
|
||||
{
|
||||
public class FileStationListResponse
|
||||
{
|
||||
public List<FileStationListFileInfoResponse> Files { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string> _cache;
|
||||
|
||||
public SerialNumberProvider(ICacheManager cacheManager,
|
||||
IDSMInfoProxy proxy,
|
||||
Logger logger)
|
||||
{
|
||||
_proxy = proxy;
|
||||
_cache = cacheManager.GetCache<string>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SharedFolderMapping> _cache;
|
||||
|
||||
public SharedFolderResolver(ICacheManager cacheManager,
|
||||
IFileStationProxy proxy,
|
||||
Logger logger)
|
||||
{
|
||||
_proxy = proxy;
|
||||
_cache = cacheManager.GetCache<SharedFolderMapping>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DownloadStationSettings>
|
||||
{
|
||||
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<DownloadStationTask> 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<ValidationFailure> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DownloadStationSettings>
|
||||
{
|
||||
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<DownloadStationTask> 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<ValidationFailure> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/NzbDrone.Core/Download/Clients/Flood/Flood.cs
Normal file
95
src/NzbDrone.Core/Download/Clients/Flood/Flood.cs
Normal file
|
|
@ -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<FloodSettings>
|
||||
{
|
||||
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<string> HandleTags(ReleaseInfo release, FloodSettings settings)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
|
||||
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<ValidationFailure> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
213
src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs
Normal file
213
src/NzbDrone.Core/Download/Clients/Flood/FloodProxy.cs
Normal file
|
|
@ -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<string> tags, FloodSettings settings);
|
||||
void AddTorrentByFile(string file, IEnumerable<string> tags, FloodSettings settings);
|
||||
void DeleteTorrent(string hash, bool deleteData, FloodSettings settings);
|
||||
Dictionary<string, Torrent> GetTorrents(FloodSettings settings);
|
||||
List<string> GetTorrentContentPaths(string hash, FloodSettings settings);
|
||||
void SetTorrentsTags(string hash, IEnumerable<string> tags, FloodSettings settings);
|
||||
}
|
||||
|
||||
public class FloodProxy : IFloodProxy
|
||||
{
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly Logger _logger;
|
||||
private readonly ICached<Dictionary<string, string>> _authCookieCache;
|
||||
|
||||
public FloodProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
_authCookieCache = cacheManager.GetCache<Dictionary<string, string>>(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<string, string> 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<string, object>
|
||||
{
|
||||
{ "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<string> tags, FloodSettings settings)
|
||||
{
|
||||
var addRequest = BuildRequest(settings).Resource("/torrents/add-files").Post().Build();
|
||||
|
||||
var body = new Dictionary<string, object>
|
||||
{
|
||||
{ "files", new List<string> { 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<string> tags, FloodSettings settings)
|
||||
{
|
||||
var addRequest = BuildRequest(settings).Resource("/torrents/add-urls").Post().Build();
|
||||
|
||||
var body = new Dictionary<string, object>
|
||||
{
|
||||
{ "urls", new List<string> { 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<string, object>
|
||||
{
|
||||
{ "hashes", new List<string> { hash } },
|
||||
{ "deleteData", deleteData }
|
||||
};
|
||||
deleteRequest.SetContent(body.ToJson());
|
||||
|
||||
HandleRequest(deleteRequest, settings);
|
||||
}
|
||||
|
||||
public Dictionary<string, Torrent> GetTorrents(FloodSettings settings)
|
||||
{
|
||||
var getTorrentsRequest = BuildRequest(settings).Resource("/torrents").Build();
|
||||
|
||||
getTorrentsRequest.Method = HttpMethod.GET;
|
||||
|
||||
return Json.Deserialize<TorrentListSummary>(HandleRequest(getTorrentsRequest, settings).Content).Torrents;
|
||||
}
|
||||
|
||||
public List<string> GetTorrentContentPaths(string hash, FloodSettings settings)
|
||||
{
|
||||
var contentsRequest = BuildRequest(settings).Resource($"/torrents/{hash}/contents").Build();
|
||||
|
||||
contentsRequest.Method = HttpMethod.GET;
|
||||
|
||||
return Json.Deserialize<List<TorrentContent>>(HandleRequest(contentsRequest, settings).Content).ConvertAll(content => content.Path);
|
||||
}
|
||||
|
||||
public void SetTorrentsTags(string hash, IEnumerable<string> tags, FloodSettings settings)
|
||||
{
|
||||
var tagsRequest = BuildRequest(settings).Resource("/torrents/tags").Build();
|
||||
|
||||
tagsRequest.Method = HttpMethod.PATCH;
|
||||
|
||||
var body = new Dictionary<string, object>
|
||||
{
|
||||
{ "hashes", new List<string> { hash } },
|
||||
{ "tags", tags.ToList() }
|
||||
};
|
||||
tagsRequest.SetContent(body.ToJson());
|
||||
|
||||
HandleRequest(tagsRequest, settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs
Normal file
72
src/NzbDrone.Core/Download/Clients/Flood/FloodSettings.cs
Normal file
|
|
@ -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<FloodSettings>
|
||||
{
|
||||
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<int>();
|
||||
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<string> 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<int> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
35
src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs
Normal file
35
src/NzbDrone.Core/Download/Clients/Flood/Types/Torrent.cs
Normal file
|
|
@ -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<string> Status { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "tags")]
|
||||
public List<string> Tags { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, Torrent> Torrents { get; set; }
|
||||
}
|
||||
}
|
||||
106
src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs
Normal file
106
src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs
Normal file
|
|
@ -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<HadoukenSettings>
|
||||
{
|
||||
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<ValidationFailure> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
183
src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs
Normal file
183
src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs
Normal file
|
|
@ -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<string, object> 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<HadoukenSystemInfo>(settings, "core.getSystemInfo");
|
||||
}
|
||||
|
||||
public HadoukenTorrent[] GetTorrents(HadoukenSettings settings)
|
||||
{
|
||||
var result = ProcessRequest<HadoukenTorrentResponse>(settings, "webui.list");
|
||||
|
||||
return GetTorrents(result.Torrents);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, object> GetConfig(HadoukenSettings settings)
|
||||
{
|
||||
return ProcessRequest<IReadOnlyDictionary<string, object>>(settings, "webui.getSettings");
|
||||
}
|
||||
|
||||
public string AddTorrentFile(HadoukenSettings settings, byte[] fileContent)
|
||||
{
|
||||
return ProcessRequest<string>(settings, "webui.addTorrent", "file", Convert.ToBase64String(fileContent), new { label = settings.Category });
|
||||
}
|
||||
|
||||
public void AddTorrentUri(HadoukenSettings settings, string torrentUrl)
|
||||
{
|
||||
ProcessRequest<string>(settings, "webui.addTorrent", "url", torrentUrl, new { label = settings.Category });
|
||||
}
|
||||
|
||||
public void RemoveTorrent(HadoukenSettings settings, string downloadId)
|
||||
{
|
||||
ProcessRequest<bool>(settings, "webui.perform", "remove", new string[] { downloadId });
|
||||
}
|
||||
|
||||
public void RemoveTorrentAndData(HadoukenSettings settings, string downloadId)
|
||||
{
|
||||
ProcessRequest<bool>(settings, "webui.perform", "removedata", new string[] { downloadId });
|
||||
}
|
||||
|
||||
private T ProcessRequest<T>(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<JsonRpcResponse<T>>(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<HadoukenTorrent>();
|
||||
}
|
||||
|
||||
var torrents = new List<HadoukenTorrent>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HadoukenSettings>
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> Versions { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NzbDrone.Core.Download.Clients.Hadouken.Models
|
||||
{
|
||||
public class HadoukenTorrentResponse
|
||||
{
|
||||
public object[][] Torrents { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs
Normal file
137
src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortex.cs
Normal file
|
|
@ -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<NzbVortexSettings>
|
||||
{
|
||||
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<NzbVortexGroup> GetGroups()
|
||||
{
|
||||
return _proxy.GetGroups(Settings);
|
||||
}
|
||||
|
||||
protected override void Test(List<ValidationFailure> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NzbDrone.Core.Download.Clients.NzbVortex
|
||||
{
|
||||
public class NzbVortexGroup
|
||||
{
|
||||
public string GroupName { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace NzbDrone.Core.Download.Clients.NzbVortex
|
||||
{
|
||||
public enum NzbVortexLoginResultType
|
||||
{
|
||||
Successful,
|
||||
Failed
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NzbDrone.Core.Download.Clients.NzbVortex
|
||||
{
|
||||
public enum NzbVortexPriority
|
||||
{
|
||||
Low = -1,
|
||||
Normal = 0,
|
||||
High = 1,
|
||||
}
|
||||
}
|
||||
218
src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs
Normal file
218
src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs
Normal file
|
|
@ -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<NzbVortexGroup> GetGroups(NzbVortexSettings settings);
|
||||
List<NzbVortexQueueItem> GetQueue(int doneLimit, NzbVortexSettings settings);
|
||||
List<NzbVortexFile> GetFiles(int id, NzbVortexSettings settings);
|
||||
}
|
||||
|
||||
public class NzbVortexProxy : INzbVortexProxy
|
||||
{
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly Logger _logger;
|
||||
|
||||
private readonly ICached<string> _authSessionIdCache;
|
||||
|
||||
public NzbVortexProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
|
||||
_authSessionIdCache = cacheManager.GetCache<string>(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<NzbVortexAddResponse>(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<NzbVortexResponseBase>(requestBuilder, true, settings);
|
||||
}
|
||||
|
||||
public NzbVortexVersionResponse GetVersion(NzbVortexSettings settings)
|
||||
{
|
||||
var requestBuilder = BuildRequest(settings).Resource("app/appversion");
|
||||
|
||||
var response = ProcessRequest<NzbVortexVersionResponse>(requestBuilder, false, settings);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public NzbVortexApiVersionResponse GetApiVersion(NzbVortexSettings settings)
|
||||
{
|
||||
var requestBuilder = BuildRequest(settings).Resource("app/apilevel");
|
||||
|
||||
var response = ProcessRequest<NzbVortexApiVersionResponse>(requestBuilder, false, settings);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public List<NzbVortexGroup> GetGroups(NzbVortexSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings).Resource("group");
|
||||
var response = ProcessRequest<NzbVortexGroupResponse>(request, true, settings);
|
||||
|
||||
return response.Groups;
|
||||
}
|
||||
|
||||
public List<NzbVortexQueueItem> 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<NzbVortexQueueResponse>(requestBuilder, true, settings);
|
||||
|
||||
return response.Items;
|
||||
}
|
||||
|
||||
public List<NzbVortexFile> GetFiles(int id, NzbVortexSettings settings)
|
||||
{
|
||||
var requestBuilder = BuildRequest(settings).Resource(string.Format("file/{0}", id));
|
||||
|
||||
var response = ProcessRequest<NzbVortexFilesResponse>(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<T>(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<T>(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<T>(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<NzbVortexAuthNonceResponse>(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<NzbVortexAuthResponse>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NzbDrone.Core.Download.Clients.NzbVortex
|
||||
{
|
||||
public enum NzbVortexResultType
|
||||
{
|
||||
Ok,
|
||||
NotLoggedIn,
|
||||
UnknownCommand
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NzbVortexSettings>
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses
|
||||
{
|
||||
public class NzbVortexApiVersionResponse : NzbVortexResponseBase
|
||||
{
|
||||
public string ApiLevel { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses
|
||||
{
|
||||
public class NzbVortexAuthNonceResponse
|
||||
{
|
||||
public string AuthNonce { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue