New: Download Clients for Manual Grabs

This commit is contained in:
Qstick 2021-03-18 00:07:25 -04:00
parent fd27018caa
commit 881313ef2b
209 changed files with 12229 additions and 127 deletions

View file

@ -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}

View file

@ -52,6 +52,10 @@ const links = [
title: translate('Apps'),
to: '/settings/applications'
},
{
title: translate('DownloadClients'),
to: '/settings/downloadclients'
},
{
title: translate('Connect'),
to: '/settings/connect'

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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));
}
};
}

View file

@ -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;

View file

@ -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);

View file

@ -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';
}
}

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,5 @@
.downloadClients {
display: flex;
justify-content: center;
flex-wrap: wrap;
}

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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;
}

View file

@ -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;

View file

@ -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);

View file

@ -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"

View 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;
});
}
}
};

View file

@ -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)

View file

@ -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,

View file

@ -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();

View 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();
}
}
}

View file

@ -0,0 +1,8 @@
namespace NzbDrone.Core.Download.Clients.Deluge
{
public class DelugeError
{
public string Message { get; set; }
public int Code { get; set; }
}
}

View 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;
}
}
}

View 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; }
}
}

View file

@ -0,0 +1,8 @@
namespace NzbDrone.Core.Download.Clients.Deluge
{
public enum DelugePriority
{
Last = 0,
First = 1
}
}

View 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();
}
}
}

View 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));
}
}
}

View 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; }
}
}

View file

@ -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";
}
}

View file

@ -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; }
}
}

View file

@ -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)
{
}
}
}

View file

@ -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)
{
}
}
}

View file

@ -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)
{
}
}
}

View file

@ -0,0 +1,12 @@
namespace NzbDrone.Core.Download.Clients.DownloadStation
{
public enum DiskStationApi
{
Info,
Auth,
DownloadStationInfo,
DownloadStationTask,
FileStationList,
DSMInfo,
}
}

View file

@ -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[] { '/', '\\' });
}
}
}
}
}

View file

@ -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));
}
}
}

View file

@ -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
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -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;
}
}
}

View file

@ -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);
}
}
}

View file

@ -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;
}
}
}

View file

@ -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);
}
}
}

View file

@ -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();
}
}
}

View file

@ -0,0 +1,10 @@
using Newtonsoft.Json;
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
{
public class DSMInfoResponse
{
[JsonProperty("serial")]
public string SerialNumber { get; set; }
}
}

View file

@ -0,0 +1,7 @@
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
{
public class DiskStationAuthResponse
{
public string SId { get; set; }
}
}

View file

@ -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, "Cant 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";
}
}
}

View file

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
{
public class DiskStationApiInfoResponse : Dictionary<string, DiskStationApiInfo>
{
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses
{
public class FileStationListResponse
{
public List<FileStationListFileInfoResponse> Files { get; set; }
}
}

View file

@ -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();
}
}
}

View file

@ -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}";
}
}
}

View file

@ -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;
}
}
}

View file

@ -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();
}
}
}

View file

@ -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();
}
}
}

View 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();
}
}
}

View 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);
}
}
}

View 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));
}
}
}

View file

@ -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
}
}

View 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; }
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View 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();
}
}
}

View 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;
}
}
}

View file

@ -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));
}
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,7 @@
namespace NzbDrone.Core.Download.Clients.Hadouken.Models
{
public class HadoukenTorrentResponse
{
public object[][] Torrents { get; set; }
}
}

View file

@ -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
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View 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();
}
}
}

View file

@ -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)
{
}
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,7 @@
namespace NzbDrone.Core.Download.Clients.NzbVortex
{
public class NzbVortexGroup
{
public string GroupName { get; set; }
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,8 @@
namespace NzbDrone.Core.Download.Clients.NzbVortex
{
public enum NzbVortexLoginResultType
{
Successful,
Failed
}
}

View file

@ -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)
{
}
}
}

View file

@ -0,0 +1,9 @@
namespace NzbDrone.Core.Download.Clients.NzbVortex
{
public enum NzbVortexPriority
{
Low = -1,
Normal = 0,
High = 1,
}
}

View 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);
}
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,9 @@
namespace NzbDrone.Core.Download.Clients.NzbVortex
{
public enum NzbVortexResultType
{
Ok,
NotLoggedIn,
UnknownCommand
}
}

View file

@ -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));
}
}
}

View file

@ -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
}
}

View file

@ -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; }
}
}

View file

@ -0,0 +1,7 @@
namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses
{
public class NzbVortexApiVersionResponse : NzbVortexResponseBase
{
public string ApiLevel { get; set; }
}
}

View file

@ -0,0 +1,7 @@
namespace NzbDrone.Core.Download.Clients.NzbVortex.Responses
{
public class NzbVortexAuthNonceResponse
{
public string AuthNonce { get; set; }
}
}

View file

@ -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