New: Define multiple mapped categories for Download Clients

Fixes #170
pull/1239/head
Qstick 2 years ago
parent a110412665
commit 0d918a0aa9

@ -16,6 +16,7 @@ import FormInputHelpText from './FormInputHelpText';
import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector'; import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector';
import InfoInput from './InfoInput'; import InfoInput from './InfoInput';
import KeyValueListInput from './KeyValueListInput'; import KeyValueListInput from './KeyValueListInput';
import NewznabCategorySelectInputConnector from './NewznabCategorySelectInputConnector';
import NumberInput from './NumberInput'; import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector'; import OAuthInputConnector from './OAuthInputConnector';
import PasswordInput from './PasswordInput'; import PasswordInput from './PasswordInput';
@ -68,6 +69,9 @@ function getComponent(type) {
case inputTypes.PATH: case inputTypes.PATH:
return PathInputConnector; return PathInputConnector;
case inputTypes.CATEGORY_SELECT:
return NewznabCategorySelectInputConnector;
case inputTypes.INDEXER_FLAGS_SELECT: case inputTypes.INDEXER_FLAGS_SELECT:
return IndexerFlagsSelectInputConnector; return IndexerFlagsSelectInputConnector;

@ -31,7 +31,7 @@ function createMapStateToProps() {
}); });
return { return {
value, value: value || [],
values values
}; };
} }

@ -8,6 +8,7 @@ export const DEVICE = 'device';
export const KEY_VALUE_LIST = 'keyValueList'; export const KEY_VALUE_LIST = 'keyValueList';
export const INFO = 'info'; export const INFO = 'info';
export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect'; export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect';
export const CATEGORY_SELECT = 'newznabCategorySelect';
export const NUMBER = 'number'; export const NUMBER = 'number';
export const OAUTH = 'oauth'; export const OAUTH = 'oauth';
export const PASSWORD = 'password'; export const PASSWORD = 'password';
@ -32,6 +33,7 @@ export const all = [
KEY_VALUE_LIST, KEY_VALUE_LIST,
INFO, INFO,
MOVIE_MONITORED_SELECT, MOVIE_MONITORED_SELECT,
CATEGORY_SELECT,
NUMBER, NUMBER,
OAUTH, OAUTH,
PASSWORD, PASSWORD,

@ -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 AddCategoryModalContentConnector from './AddCategoryModalContentConnector';
function AddCategoryModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddCategoryModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddCategoryModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddCategoryModal;

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import AddCategoryModal from './AddCategoryModal';
function createMapDispatchToProps(dispatch, props) {
const section = 'settings.downloadClientCategories';
return {
dispatchClearPendingChanges() {
dispatch(clearPendingChanges({ section }));
}
};
}
class AddCategoryModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.dispatchClearPendingChanges();
this.props.onModalClose();
};
//
// Render
render() {
const {
dispatchClearPendingChanges,
...otherProps
} = this.props;
return (
<AddCategoryModal
{...otherProps}
onModalClose={this.onModalClose}
/>
);
}
}
AddCategoryModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(AddCategoryModalConnector);

@ -0,0 +1,5 @@
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: auto;
}

@ -0,0 +1,111 @@
import PropTypes from 'prop-types';
import React from 'react';
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 Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
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 './AddCategoryModalContent.css';
function AddCategoryModalContent(props) {
const {
advancedSettings,
item,
onInputChange,
onFieldChange,
onCancelPress,
onSavePress,
onDeleteSpecificationPress,
...otherProps
} = props;
const {
id,
clientCategory,
categories
} = item;
return (
<ModalContent onModalClose={onCancelPress}>
<ModalHeader>
{`${id ? 'Edit' : 'Add'} Category`}
</ModalHeader>
<ModalBody>
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>
{translate('DownloadClientCategory')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="clientCategory"
{...clientCategory}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('MappedCategories')}
</FormLabel>
<FormInputGroup
type={inputTypes.CATEGORY_SELECT}
name="categories"
{...categories}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteSpecificationPress}
>
{translate('Delete')}
</Button>
}
<Button
onPress={onCancelPress}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={false}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
AddCategoryModalContent.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired,
onCancelPress: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onDeleteSpecificationPress: PropTypes.func
};
export default AddCategoryModalContent;

@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearDownloadClientCategoryPending, saveDownloadClientCategory, setDownloadClientCategoryFieldValue, setDownloadClientCategoryValue } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import AddCategoryModalContent from './AddCategoryModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('downloadClientCategories'),
(advancedSettings, specification) => {
return {
advancedSettings,
...specification
};
}
);
}
const mapDispatchToProps = {
setDownloadClientCategoryValue,
setDownloadClientCategoryFieldValue,
saveDownloadClientCategory,
clearDownloadClientCategoryPending
};
class AddCategoryModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setDownloadClientCategoryValue({ name, value });
};
onFieldChange = ({ name, value }) => {
this.props.setDownloadClientCategoryFieldValue({ name, value });
};
onCancelPress = () => {
this.props.clearDownloadClientCategoryPending();
this.props.onModalClose();
};
onSavePress = () => {
this.props.saveDownloadClientCategory({ id: this.props.id });
this.props.onModalClose();
};
//
// Render
render() {
return (
<AddCategoryModalContent
{...this.props}
onCancelPress={this.onCancelPress}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
AddCategoryModalContentConnector.propTypes = {
id: PropTypes.number,
item: PropTypes.object.isRequired,
setDownloadClientCategoryValue: PropTypes.func.isRequired,
setDownloadClientCategoryFieldValue: PropTypes.func.isRequired,
clearDownloadClientCategoryPending: PropTypes.func.isRequired,
saveDownloadClientCategory: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddCategoryModalContentConnector);

@ -0,0 +1,32 @@
.customFormat {
composes: card from '~Components/Card.css';
width: 300px;
}
.nameContainer {
display: flex;
justify-content: space-between;
}
.name {
@add-mixin truncate;
margin-bottom: 5px;
font-weight: 300;
font-size: 20px;
}
.labels {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
pointer-events: all;
}
.tooltipLabel {
composes: label from '~Components/Label.css';
margin: 0;
border: none;
}

@ -0,0 +1,111 @@
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 AddCategoryModalConnector from './AddCategoryModalConnector';
import styles from './Category.css';
class Category extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditSpecificationModalOpen: false,
isDeleteSpecificationModalOpen: false
};
}
//
// Listeners
onEditSpecificationPress = () => {
this.setState({ isEditSpecificationModalOpen: true });
};
onEditSpecificationModalClose = () => {
this.setState({ isEditSpecificationModalOpen: false });
};
onDeleteSpecificationPress = () => {
this.setState({
isEditSpecificationModalOpen: false,
isDeleteSpecificationModalOpen: true
});
};
onDeleteSpecificationModalClose = () => {
this.setState({ isDeleteSpecificationModalOpen: false });
};
onConfirmDeleteSpecification = () => {
this.props.onConfirmDeleteSpecification(this.props.id);
};
//
// Lifecycle
render() {
const {
id,
clientCategory,
categories
} = this.props;
return (
<Card
className={styles.customFormat}
overlayContent={true}
onPress={this.onEditSpecificationPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{clientCategory}
</div>
</div>
<Label kind={kinds.PRIMARY}>
{`${categories.length} ${categories.length > 1 ? translate('Categories') : translate('Category')}`}
</Label>
<AddCategoryModalConnector
id={id}
isOpen={this.state.isEditSpecificationModalOpen}
onModalClose={this.onEditSpecificationModalClose}
onDeleteSpecificationPress={this.onDeleteSpecificationPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteSpecificationModalOpen}
kind={kinds.DANGER}
title={translate('DeleteClientCategory')}
message={
<div>
<div>
{translate('AreYouSureYouWantToDeleteCategory', [name])}
</div>
</div>
}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteSpecification}
onCancel={this.onDeleteSpecificationModalClose}
/>
</Card>
);
}
}
Category.propTypes = {
id: PropTypes.number.isRequired,
categories: PropTypes.arrayOf(PropTypes.number).isRequired,
clientCategory: PropTypes.string.isRequired,
onConfirmDeleteSpecification: PropTypes.func.isRequired
};
export default Category;

@ -1,11 +1,14 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form'; import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -13,12 +16,33 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props'; import { icons, inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import AddCategoryModalConnector from './Categories/AddCategoryModalConnector';
import Category from './Categories/Category';
import styles from './EditDownloadClientModalContent.css'; import styles from './EditDownloadClientModalContent.css';
class EditDownloadClientModalContent extends Component { class EditDownloadClientModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddCategoryModalOpen: false
};
}
onAddCategoryPress = () => {
this.setState({ isAddCategoryModalOpen: true });
};
onAddCategoryModalClose = () => {
this.setState({ isAddCategoryModalOpen: false });
};
// //
// Render // Render
@ -27,6 +51,7 @@ class EditDownloadClientModalContent extends Component {
advancedSettings, advancedSettings,
isFetching, isFetching,
error, error,
categories,
isSaving, isSaving,
isTesting, isTesting,
saveError, saveError,
@ -37,19 +62,27 @@ class EditDownloadClientModalContent extends Component {
onSavePress, onSavePress,
onTestPress, onTestPress,
onDeleteDownloadClientPress, onDeleteDownloadClientPress,
onConfirmDeleteCategory,
...otherProps ...otherProps
} = this.props; } = this.props;
const {
isAddCategoryModalOpen
} = this.state;
const { const {
id, id,
implementationName, implementationName,
name, name,
enable, enable,
priority, priority,
supportsCategories,
fields, fields,
message message
} = item; } = item;
console.log(supportsCategories);
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>
@ -136,6 +169,43 @@ class EditDownloadClientModalContent extends Component {
/> />
</FormGroup> </FormGroup>
{
supportsCategories.value ?
<FieldSet legend={translate('MappedCategories')}>
<div className={styles.customFormats}>
{
categories.map((tag) => {
return (
<Category
key={tag.id}
{...tag}
onConfirmDeleteSpecification={onConfirmDeleteCategory}
/>
);
})
}
<Card
className={styles.addCategory}
onPress={this.onAddCategoryPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={25}
/>
</div>
</Card>
</div>
</FieldSet> :
null
}
<AddCategoryModalConnector
isOpen={isAddCategoryModalOpen}
onModalClose={this.onAddCategoryModalClose}
/>
</Form> </Form>
} }
</ModalBody> </ModalBody>
@ -185,13 +255,15 @@ EditDownloadClientModalContent.propTypes = {
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object, saveError: PropTypes.object,
isTesting: PropTypes.bool.isRequired, isTesting: PropTypes.bool.isRequired,
categories: PropTypes.arrayOf(PropTypes.object),
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired, onFieldChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired, onTestPress: PropTypes.func.isRequired,
onDeleteDownloadClientPress: PropTypes.func onDeleteDownloadClientPress: PropTypes.func,
onConfirmDeleteCategory: PropTypes.func.isRequired
}; };
export default EditDownloadClientModalContent; export default EditDownloadClientModalContent;

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { saveDownloadClient, setDownloadClientFieldValue, setDownloadClientValue, testDownloadClient } from 'Store/Actions/settingsActions'; import { deleteDownloadClientCategory, fetchDownloadClientCategories, saveDownloadClient, setDownloadClientFieldValue, setDownloadClientValue, testDownloadClient } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditDownloadClientModalContent from './EditDownloadClientModalContent'; import EditDownloadClientModalContent from './EditDownloadClientModalContent';
@ -10,10 +10,12 @@ function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.settings.advancedSettings, (state) => state.settings.advancedSettings,
createProviderSettingsSelector('downloadClients'), createProviderSettingsSelector('downloadClients'),
(advancedSettings, downloadClient) => { (state) => state.settings.downloadClientCategories,
(advancedSettings, downloadClient, categories) => {
return { return {
advancedSettings, advancedSettings,
...downloadClient ...downloadClient,
categories: categories.items
}; };
} }
); );
@ -23,7 +25,9 @@ const mapDispatchToProps = {
setDownloadClientValue, setDownloadClientValue,
setDownloadClientFieldValue, setDownloadClientFieldValue,
saveDownloadClient, saveDownloadClient,
testDownloadClient testDownloadClient,
fetchDownloadClientCategories,
deleteDownloadClientCategory
}; };
class EditDownloadClientModalContentConnector extends Component { class EditDownloadClientModalContentConnector extends Component {
@ -31,6 +35,14 @@ class EditDownloadClientModalContentConnector extends Component {
// //
// Lifecycle // Lifecycle
componentDidMount() {
const {
id,
tagsFromId
} = this.props;
this.props.fetchDownloadClientCategories({ id: tagsFromId || id });
}
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose(); this.props.onModalClose();
@ -56,6 +68,10 @@ class EditDownloadClientModalContentConnector extends Component {
this.props.testDownloadClient({ id: this.props.id }); this.props.testDownloadClient({ id: this.props.id });
}; };
onConfirmDeleteCategory = (id) => {
this.props.deleteDownloadClientCategory({ id });
};
// //
// Render // Render
@ -67,6 +83,7 @@ class EditDownloadClientModalContentConnector extends Component {
onTestPress={this.onTestPress} onTestPress={this.onTestPress}
onInputChange={this.onInputChange} onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange} onFieldChange={this.onFieldChange}
onConfirmDeleteCategory={this.onConfirmDeleteCategory}
/> />
); );
} }
@ -74,10 +91,13 @@ class EditDownloadClientModalContentConnector extends Component {
EditDownloadClientModalContentConnector.propTypes = { EditDownloadClientModalContentConnector.propTypes = {
id: PropTypes.number, id: PropTypes.number,
tagsFromId: PropTypes.number,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object, saveError: PropTypes.object,
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
fetchDownloadClientCategories: PropTypes.func.isRequired,
deleteDownloadClientCategory: PropTypes.func.isRequired,
setDownloadClientValue: PropTypes.func.isRequired, setDownloadClientValue: PropTypes.func.isRequired,
setDownloadClientFieldValue: PropTypes.func.isRequired, setDownloadClientFieldValue: PropTypes.func.isRequired,
saveDownloadClient: PropTypes.func.isRequired, saveDownloadClient: PropTypes.func.isRequired,

@ -0,0 +1,171 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import getNextId from 'Utilities/State/getNextId';
import getProviderState from 'Utilities/State/getProviderState';
import getSectionState from 'Utilities/State/getSectionState';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import { removeItem, set, update, updateItem } from '../baseActions';
//
// Variables
const section = 'settings.downloadClientCategories';
//
// Actions Types
export const FETCH_DOWNLOAD_CLIENT_CATEGORIES = 'settings/downloadClientCategories/fetchDownloadClientCategories';
export const FETCH_DOWNLOAD_CLIENT_CATEGORY_SCHEMA = 'settings/downloadClientCategories/fetchDownloadClientCategorySchema';
export const SELECT_DOWNLOAD_CLIENT_CATEGORY_SCHEMA = 'settings/downloadClientCategories/selectDownloadClientCategorySchema';
export const SET_DOWNLOAD_CLIENT_CATEGORY_VALUE = 'settings/downloadClientCategories/setDownloadClientCategoryValue';
export const SET_DOWNLOAD_CLIENT_CATEGORY_FIELD_VALUE = 'settings/downloadClientCategories/setDownloadClientCategoryFieldValue';
export const SAVE_DOWNLOAD_CLIENT_CATEGORY = 'settings/downloadClientCategories/saveDownloadClientCategory';
export const DELETE_DOWNLOAD_CLIENT_CATEGORY = 'settings/downloadClientCategories/deleteDownloadClientCategory';
export const DELETE_ALL_DOWNLOAD_CLIENT_CATEGORY = 'settings/downloadClientCategories/deleteAllDownloadClientCategory';
export const CLEAR_DOWNLOAD_CLIENT_CATEGORIES = 'settings/downloadClientCategories/clearDownloadClientCategories';
export const CLEAR_DOWNLOAD_CLIENT_CATEGORY_PENDING = 'settings/downloadClientCategories/clearDownloadClientCategoryPending';
//
// Action Creators
export const fetchDownloadClientCategories = createThunk(FETCH_DOWNLOAD_CLIENT_CATEGORIES);
export const fetchDownloadClientCategorySchema = createThunk(FETCH_DOWNLOAD_CLIENT_CATEGORY_SCHEMA);
export const selectDownloadClientCategorySchema = createAction(SELECT_DOWNLOAD_CLIENT_CATEGORY_SCHEMA);
export const saveDownloadClientCategory = createThunk(SAVE_DOWNLOAD_CLIENT_CATEGORY);
export const deleteDownloadClientCategory = createThunk(DELETE_DOWNLOAD_CLIENT_CATEGORY);
export const deleteAllDownloadClientCategory = createThunk(DELETE_ALL_DOWNLOAD_CLIENT_CATEGORY);
export const setDownloadClientCategoryValue = createAction(SET_DOWNLOAD_CLIENT_CATEGORY_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setDownloadClientCategoryFieldValue = createAction(SET_DOWNLOAD_CLIENT_CATEGORY_FIELD_VALUE, (payload) => {
return {
section,
...payload
};
});
export const clearDownloadClientCategory = createAction(CLEAR_DOWNLOAD_CLIENT_CATEGORIES);
export const clearDownloadClientCategoryPending = createThunk(CLEAR_DOWNLOAD_CLIENT_CATEGORY_PENDING);
//
// Details
export default {
//
// State
defaultState: {
isPopulated: false,
error: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: [],
selectedSchema: {},
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_DOWNLOAD_CLIENT_CATEGORIES]: (getState, payload, dispatch) => {
let tags = [];
if (payload.id) {
const cfState = getSectionState(getState(), 'settings.downloadClients', true);
const cf = cfState.items[cfState.itemMap[payload.id]];
tags = cf.categories.map((tag, i) => {
return {
id: i + 1,
...tag
};
});
}
dispatch(batchActions([
update({ section, data: tags }),
set({
section,
isPopulated: true
})
]));
},
[SAVE_DOWNLOAD_CLIENT_CATEGORY]: (getState, payload, dispatch) => {
const {
id,
...otherPayload
} = payload;
const saveData = getProviderState({ id, ...otherPayload }, getState, section, false);
console.log(saveData);
// we have to set id since not actually posting to server yet
if (!saveData.id) {
saveData.id = getNextId(getState().settings.downloadClientCategories.items);
}
dispatch(batchActions([
updateItem({ section, ...saveData }),
set({
section,
pendingChanges: {}
})
]));
},
[DELETE_DOWNLOAD_CLIENT_CATEGORY]: (getState, payload, dispatch) => {
const id = payload.id;
return dispatch(removeItem({ section, id }));
},
[DELETE_ALL_DOWNLOAD_CLIENT_CATEGORY]: (getState, payload, dispatch) => {
return dispatch(set({
section,
items: []
}));
},
[CLEAR_DOWNLOAD_CLIENT_CATEGORY_PENDING]: (getState, payload, dispatch) => {
return dispatch(set({
section,
pendingChanges: {}
}));
}
},
//
// Reducers
reducers: {
[SET_DOWNLOAD_CLIENT_CATEGORY_VALUE]: createSetSettingValueReducer(section),
[SET_DOWNLOAD_CLIENT_CATEGORY_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
[SELECT_DOWNLOAD_CLIENT_CATEGORY_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
return selectedSchema;
});
},
[CLEAR_DOWNLOAD_CLIENT_CATEGORIES]: createClearReducer(section, {
isPopulated: false,
error: null,
items: []
})
}
};

@ -9,6 +9,7 @@ import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks'; import { createThunk } from 'Store/thunks';
import selectProviderSchema from 'Utilities/State/selectProviderSchema'; import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import { set } from '../baseActions';
// //
// Variables // Variables
@ -90,10 +91,34 @@ export default {
[FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'), [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'),
[FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'), [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'),
[SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'), [SAVE_DOWNLOAD_CLIENT]: (getState, payload, dispatch) => {
// move the format tags in as a pending change
const state = getState();
const pendingChanges = state.settings.downloadClients.pendingChanges;
pendingChanges.categories = state.settings.downloadClientCategories.items;
dispatch(set({
section,
pendingChanges
}));
createSaveProviderHandler(section, '/downloadclient')(getState, payload, dispatch);
},
[CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section), [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section),
[DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'),
[TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
[TEST_DOWNLOAD_CLIENT]: (getState, payload, dispatch) => {
const state = getState();
const pendingChanges = state.settings.downloadClients.pendingChanges;
pendingChanges.categories = state.settings.downloadClientCategories.items;
dispatch(set({
section,
pendingChanges
}));
createTestProviderHandler(section, '/downloadclient')(getState, payload, dispatch);
},
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient')
}, },

@ -4,6 +4,7 @@ import createHandleActions from './Creators/createHandleActions';
import applications from './Settings/applications'; import applications from './Settings/applications';
import appProfiles from './Settings/appProfiles'; import appProfiles from './Settings/appProfiles';
import development from './Settings/development'; import development from './Settings/development';
import downloadClientCategories from './Settings/downloadClientCategories';
import downloadClients from './Settings/downloadClients'; import downloadClients from './Settings/downloadClients';
import general from './Settings/general'; import general from './Settings/general';
import indexerCategories from './Settings/indexerCategories'; import indexerCategories from './Settings/indexerCategories';
@ -11,6 +12,7 @@ import indexerProxies from './Settings/indexerProxies';
import notifications from './Settings/notifications'; import notifications from './Settings/notifications';
import ui from './Settings/ui'; import ui from './Settings/ui';
export * from './Settings/downloadClientCategories';
export * from './Settings/downloadClients'; export * from './Settings/downloadClients';
export * from './Settings/general'; export * from './Settings/general';
export * from './Settings/indexerCategories'; export * from './Settings/indexerCategories';
@ -32,6 +34,7 @@ export const section = 'settings';
export const defaultState = { export const defaultState = {
advancedSettings: false, advancedSettings: false,
downloadClientCategories: downloadClientCategories.defaultState,
downloadClients: downloadClients.defaultState, downloadClients: downloadClients.defaultState,
general: general.defaultState, general: general.defaultState,
indexerCategories: indexerCategories.defaultState, indexerCategories: indexerCategories.defaultState,
@ -61,6 +64,7 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
// Action Handlers // Action Handlers
export const actionHandlers = handleThunks({ export const actionHandlers = handleThunks({
...downloadClientCategories.actionHandlers,
...downloadClients.actionHandlers, ...downloadClients.actionHandlers,
...general.actionHandlers, ...general.actionHandlers,
...indexerCategories.actionHandlers, ...indexerCategories.actionHandlers,
@ -81,6 +85,7 @@ export const reducers = createHandleActions({
return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
}, },
...downloadClientCategories.reducers,
...downloadClients.reducers, ...downloadClients.reducers,
...general.reducers, ...general.reducers,
...indexerCategories.reducers, ...indexerCategories.reducers,

@ -0,0 +1,15 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(023)]
public class download_client_categories : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("DownloadClients")
.AddColumn("Categories").AsString().WithDefaultValue("[]");
}
}
}

@ -60,7 +60,8 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity<DownloadClientDefinition>("DownloadClients").RegisterModel() Mapper.Entity<DownloadClientDefinition>("DownloadClients").RegisterModel()
.Ignore(x => x.ImplementationName) .Ignore(x => x.ImplementationName)
.Ignore(i => i.Protocol) .Ignore(d => d.SupportsCategories)
.Ignore(d => d.Protocol)
.Ignore(d => d.Tags); .Ignore(d => d.Tags);
Mapper.Entity<NotificationDefinition>("Notifications").RegisterModel() Mapper.Entity<NotificationDefinition>("Notifications").RegisterModel()
@ -115,6 +116,7 @@ namespace NzbDrone.Core.Datastore
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<string>>()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<string>>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<ReleaseInfo>()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<ReleaseInfo>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<HashSet<int>>()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<HashSet<int>>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<DownloadClientCategory>>());
SqlMapper.AddTypeHandler(new OsPathConverter()); SqlMapper.AddTypeHandler(new OsPathConverter());
SqlMapper.RemoveTypeMap(typeof(Guid)); SqlMapper.RemoveTypeMap(typeof(Guid));
SqlMapper.RemoveTypeMap(typeof(Guid?)); SqlMapper.RemoveTypeMap(typeof(Guid?));

@ -18,6 +18,8 @@ namespace NzbDrone.Core.Download.Clients.Aria2
public override string Name => "Aria2"; public override string Name => "Aria2";
public override bool SupportsCategories => false;
public Aria2(IAria2Proxy proxy, public Aria2(IAria2Proxy proxy,
ITorrentFileInfoReader torrentFileInfoReader, ITorrentFileInfoReader torrentFileInfoReader,
IHttpClient httpClient, IHttpClient httpClient,

@ -74,6 +74,8 @@ namespace NzbDrone.Core.Download.Clients.Blackhole
public override string Name => "Torrent Blackhole"; public override string Name => "Torrent Blackhole";
public override bool SupportsCategories => false;
protected override void Test(List<ValidationFailure> failures) protected override void Test(List<ValidationFailure> failures)
{ {
failures.AddIfNotNull(TestFolder(Settings.TorrentFolder, "TorrentFolder")); failures.AddIfNotNull(TestFolder(Settings.TorrentFolder, "TorrentFolder"));

@ -45,6 +45,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole
} }
public override string Name => "Usenet Blackhole"; public override string Name => "Usenet Blackhole";
public override bool SupportsCategories => false;
protected override void Test(List<ValidationFailure> failures) protected override void Test(List<ValidationFailure> failures)
{ {

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Text.RegularExpressions;
using FluentValidation.Results; using FluentValidation.Results;
using NLog; using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
@ -38,9 +39,10 @@ namespace NzbDrone.Core.Download.Clients.Deluge
} }
// _proxy.SetTorrentSeedingConfiguration(actualHash, remoteMovie.SeedConfiguration, Settings); // _proxy.SetTorrentSeedingConfiguration(actualHash, remoteMovie.SeedConfiguration, Settings);
if (Settings.Category.IsNotNullOrWhiteSpace()) var category = GetCategoryForRelease(release) ?? Settings.Category;
if (category.IsNotNullOrWhiteSpace())
{ {
_proxy.SetTorrentLabel(actualHash, Settings.Category, Settings); _proxy.SetTorrentLabel(actualHash, category, Settings);
} }
if (Settings.Priority == (int)DelugePriority.First) if (Settings.Priority == (int)DelugePriority.First)
@ -61,9 +63,10 @@ namespace NzbDrone.Core.Download.Clients.Deluge
} }
// _proxy.SetTorrentSeedingConfiguration(actualHash, release.SeedConfiguration, Settings); // _proxy.SetTorrentSeedingConfiguration(actualHash, release.SeedConfiguration, Settings);
if (Settings.Category.IsNotNullOrWhiteSpace()) var category = GetCategoryForRelease(release) ?? Settings.Category;
if (category.IsNotNullOrWhiteSpace())
{ {
_proxy.SetTorrentLabel(actualHash, Settings.Category, Settings); _proxy.SetTorrentLabel(actualHash, category, Settings);
} }
if (Settings.Priority == (int)DelugePriority.First) if (Settings.Priority == (int)DelugePriority.First)
@ -75,6 +78,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
} }
public override string Name => "Deluge"; public override string Name => "Deluge";
public override bool SupportsCategories => true;
protected override void Test(List<ValidationFailure> failures) protected override void Test(List<ValidationFailure> failures)
{ {
@ -139,7 +143,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
private ValidationFailure TestCategory() private ValidationFailure TestCategory()
{ {
if (Settings.Category.IsNullOrWhiteSpace()) if (Categories.Count == 0)
{ {
return null; return null;
} }
@ -156,23 +160,42 @@ namespace NzbDrone.Core.Download.Clients.Deluge
var labels = _proxy.GetAvailableLabels(Settings); var labels = _proxy.GetAvailableLabels(Settings);
if (Settings.Category.IsNotNullOrWhiteSpace() && !labels.Contains(Settings.Category)) var categories = Categories.Select(c => c.ClientCategory).ToList();
{ categories.Add(Settings.Category);
_proxy.AddLabel(Settings.Category, Settings);
labels = _proxy.GetAvailableLabels(Settings);
if (!labels.Contains(Settings.Category)) foreach (var category in categories)
{
if (category.IsNotNullOrWhiteSpace() && !labels.Contains(category))
{ {
return new NzbDroneValidationFailure("Category", "Configuration of label failed") _proxy.AddLabel(category, Settings);
labels = _proxy.GetAvailableLabels(Settings);
if (!labels.Contains(category))
{ {
DetailedDescription = "Prowlarr was unable to add the label to Deluge." return new NzbDroneValidationFailure("Category", "Configuration of label failed")
}; {
DetailedDescription = "Prowlarr was unable to add the label to Deluge."
};
}
} }
} }
return null; return null;
} }
protected override void ValidateCategories(List<ValidationFailure> failures)
{
base.ValidateCategories(failures);
foreach (var label in Categories)
{
if (!Regex.IsMatch(label.ClientCategory, "^[-a-z0-9]*$"))
{
failures.AddIfNotNull(new ValidationFailure(string.Empty, "Mapped Categories allowed characters a-z, 0-9 and -"));
}
}
}
private ValidationFailure TestGetTorrents() private ValidationFailure TestGetTorrents()
{ {
try try

@ -19,7 +19,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
string[] GetAvailablePlugins(DelugeSettings settings); string[] GetAvailablePlugins(DelugeSettings settings);
string[] GetEnabledPlugins(DelugeSettings settings); string[] GetEnabledPlugins(DelugeSettings settings);
string[] GetAvailableLabels(DelugeSettings settings); string[] GetAvailableLabels(DelugeSettings settings);
DelugeLabel GetLabelOptions(DelugeSettings settings); DelugeLabel GetLabelOptions(DelugeSettings settings, string label);
void SetTorrentLabel(string hash, string label, DelugeSettings settings); void SetTorrentLabel(string hash, string label, DelugeSettings settings);
void SetTorrentConfiguration(string hash, string key, object value, DelugeSettings settings); void SetTorrentConfiguration(string hash, string key, object value, DelugeSettings settings);
void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, DelugeSettings settings); void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, DelugeSettings settings);
@ -158,9 +158,9 @@ namespace NzbDrone.Core.Download.Clients.Deluge
return response; return response;
} }
public DelugeLabel GetLabelOptions(DelugeSettings settings) public DelugeLabel GetLabelOptions(DelugeSettings settings, string label)
{ {
var response = ProcessRequest<DelugeLabel>(settings, "label.get_options", settings.Category); var response = ProcessRequest<DelugeLabel>(settings, "label.get_options", label);
return response; return response;
} }

@ -43,7 +43,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge
[FieldDefinition(4, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(4, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } 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")] [FieldDefinition(5, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback Category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")]
public string Category { get; set; } public string Category { get; set; }
[FieldDefinition(6, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing items")] [FieldDefinition(6, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing items")]

@ -18,9 +18,9 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
.When(c => c.TvDirectory.IsNotNullOrWhiteSpace()) .When(c => c.TvDirectory.IsNotNullOrWhiteSpace())
.WithMessage("Cannot start with /"); .WithMessage("Cannot start with /");
RuleFor(c => c.TvCategory).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -"); RuleFor(c => c.Category).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -");
RuleFor(c => c.TvCategory).Empty() RuleFor(c => c.Category).Empty()
.When(c => c.TvDirectory.IsNotNullOrWhiteSpace()) .When(c => c.TvDirectory.IsNotNullOrWhiteSpace())
.WithMessage("Cannot use Category and Directory"); .WithMessage("Cannot use Category and Directory");
} }
@ -45,8 +45,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
[FieldDefinition(4, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(4, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } 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.")] [FieldDefinition(5, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. 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; } public string Category { 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")] [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 string TvDirectory { get; set; }

@ -44,6 +44,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
} }
public override string Name => "Download Station"; public override string Name => "Download Station";
public override bool SupportsCategories => false;
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); 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);
@ -198,7 +199,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
if (downloadDir != null) if (downloadDir != null)
{ {
var sharedFolder = downloadDir.Split('\\', '/')[0]; var sharedFolder = downloadDir.Split('\\', '/')[0];
var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.TvCategory); var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.Category);
var folderInfo = _fileStationProxy.GetInfoFileOrDirectory($"/{downloadDir}", Settings); var folderInfo = _fileStationProxy.GetInfoFileOrDirectory($"/{downloadDir}", Settings);
@ -311,11 +312,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
{ {
return Settings.TvDirectory.TrimStart('/'); return Settings.TvDirectory.TrimStart('/');
} }
else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) else if (Settings.Category.IsNotNullOrWhiteSpace())
{ {
var destDir = GetDefaultDir(); var destDir = GetDefaultDir();
return $"{destDir.TrimEnd('/')}/{Settings.TvCategory}"; return $"{destDir.TrimEnd('/')}/{Settings.Category}";
} }
return null; return null;

@ -42,6 +42,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
} }
public override string Name => "Download Station"; public override string Name => "Download Station";
public override bool SupportsCategories => false;
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); 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);
@ -101,7 +102,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
if (downloadDir != null) if (downloadDir != null)
{ {
var sharedFolder = downloadDir.Split('\\', '/')[0]; var sharedFolder = downloadDir.Split('\\', '/')[0];
var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.TvCategory); var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.Category);
var folderInfo = _fileStationProxy.GetInfoFileOrDirectory($"/{downloadDir}", Settings); var folderInfo = _fileStationProxy.GetInfoFileOrDirectory($"/{downloadDir}", Settings);
@ -272,11 +273,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation
{ {
return Settings.TvDirectory.TrimStart('/'); return Settings.TvDirectory.TrimStart('/');
} }
else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) else if (Settings.Category.IsNotNullOrWhiteSpace())
{ {
var destDir = GetDefaultDir(); var destDir = GetDefaultDir();
return $"{destDir.TrimEnd('/')}/{Settings.TvCategory}"; return $"{destDir.TrimEnd('/')}/{Settings.Category}";
} }
return null; return null;

@ -27,7 +27,7 @@ namespace NzbDrone.Core.Download.Clients.Flood
_proxy = proxy; _proxy = proxy;
} }
private static IEnumerable<string> HandleTags(ReleaseInfo release, FloodSettings settings) private static IEnumerable<string> HandleTags(ReleaseInfo release, FloodSettings settings, string mappedCategory)
{ {
var result = new HashSet<string>(); var result = new HashSet<string>();
@ -36,6 +36,11 @@ namespace NzbDrone.Core.Download.Clients.Flood
result.UnionWith(settings.Tags); result.UnionWith(settings.Tags);
} }
if (mappedCategory != null)
{
result.Add(mappedCategory);
}
if (settings.AdditionalTags.Any()) if (settings.AdditionalTags.Any())
{ {
foreach (var additionalTag in settings.AdditionalTags) foreach (var additionalTag in settings.AdditionalTags)
@ -55,18 +60,19 @@ namespace NzbDrone.Core.Download.Clients.Flood
} }
public override string Name => "Flood"; public override string Name => "Flood";
public override bool SupportsCategories => true;
public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to remove torrents that have finished seeding when using Flood", ProviderMessageType.Warning); 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) protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent)
{ {
_proxy.AddTorrentByFile(Convert.ToBase64String(fileContent), HandleTags(release, Settings), Settings); _proxy.AddTorrentByFile(Convert.ToBase64String(fileContent), HandleTags(release, Settings, GetCategoryForRelease(release)), Settings);
return hash; return hash;
} }
protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink)
{ {
_proxy.AddTorrentByUrl(magnetLink, HandleTags(release, Settings), Settings); _proxy.AddTorrentByUrl(magnetLink, HandleTags(release, Settings, GetCategoryForRelease(release)), Settings);
return hash; return hash;
} }

@ -27,6 +27,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken
} }
public override string Name => "Hadouken"; public override string Name => "Hadouken";
public override bool SupportsCategories => true;
protected override void Test(List<ValidationFailure> failures) protected override void Test(List<ValidationFailure> failures)
{ {
@ -41,14 +42,14 @@ namespace NzbDrone.Core.Download.Clients.Hadouken
protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink) protected override string AddFromMagnetLink(ReleaseInfo release, string hash, string magnetLink)
{ {
_proxy.AddTorrentUri(Settings, magnetLink); _proxy.AddTorrentUri(Settings, magnetLink, GetCategoryForRelease(release) ?? Settings.Category);
return hash.ToUpper(); return hash.ToUpper();
} }
protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent) protected override string AddFromTorrentFile(ReleaseInfo release, string hash, string filename, byte[] fileContent)
{ {
return _proxy.AddTorrentFile(Settings, fileContent).ToUpper(); return _proxy.AddTorrentFile(Settings, fileContent, GetCategoryForRelease(release) ?? Settings.Category).ToUpper();
} }
private ValidationFailure TestConnection() private ValidationFailure TestConnection()

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net; using System.Net;
using NLog; using NLog;
@ -13,8 +13,8 @@ namespace NzbDrone.Core.Download.Clients.Hadouken
HadoukenSystemInfo GetSystemInfo(HadoukenSettings settings); HadoukenSystemInfo GetSystemInfo(HadoukenSettings settings);
HadoukenTorrent[] GetTorrents(HadoukenSettings settings); HadoukenTorrent[] GetTorrents(HadoukenSettings settings);
IReadOnlyDictionary<string, object> GetConfig(HadoukenSettings settings); IReadOnlyDictionary<string, object> GetConfig(HadoukenSettings settings);
string AddTorrentFile(HadoukenSettings settings, byte[] fileContent); string AddTorrentFile(HadoukenSettings settings, byte[] fileContent, string label);
void AddTorrentUri(HadoukenSettings settings, string torrentUrl); void AddTorrentUri(HadoukenSettings settings, string torrentUrl, string label);
void RemoveTorrent(HadoukenSettings settings, string downloadId); void RemoveTorrent(HadoukenSettings settings, string downloadId);
void RemoveTorrentAndData(HadoukenSettings settings, string downloadId); void RemoveTorrentAndData(HadoukenSettings settings, string downloadId);
} }
@ -47,14 +47,14 @@ namespace NzbDrone.Core.Download.Clients.Hadouken
return ProcessRequest<IReadOnlyDictionary<string, object>>(settings, "webui.getSettings"); return ProcessRequest<IReadOnlyDictionary<string, object>>(settings, "webui.getSettings");
} }
public string AddTorrentFile(HadoukenSettings settings, byte[] fileContent) public string AddTorrentFile(HadoukenSettings settings, byte[] fileContent, string label)
{ {
return ProcessRequest<string>(settings, "webui.addTorrent", "file", Convert.ToBase64String(fileContent), new { label = settings.Category }); return ProcessRequest<string>(settings, "webui.addTorrent", "file", Convert.ToBase64String(fileContent), new { label });
} }
public void AddTorrentUri(HadoukenSettings settings, string torrentUrl) public void AddTorrentUri(HadoukenSettings settings, string torrentUrl, string label)
{ {
ProcessRequest<string>(settings, "webui.addTorrent", "url", torrentUrl, new { label = settings.Category }); ProcessRequest<string>(settings, "webui.addTorrent", "url", torrentUrl, new { label });
} }
public void RemoveTorrent(HadoukenSettings settings, string downloadId) public void RemoveTorrent(HadoukenSettings settings, string downloadId)

@ -51,7 +51,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken
[FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } public string Password { get; set; }
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox)] [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release.")]
public string Category { get; set; } public string Category { get; set; }
public NzbDroneValidationResult Validate() public NzbDroneValidationResult Validate()

@ -29,8 +29,9 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex
protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContents) protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContents)
{ {
var priority = Settings.Priority; var priority = Settings.Priority;
var category = GetCategoryForRelease(release) ?? Settings.Category;
var response = _proxy.DownloadNzb(fileContents, filename, priority, Settings); var response = _proxy.DownloadNzb(fileContents, filename, priority, Settings, category);
if (response == null) if (response == null)
{ {
@ -41,6 +42,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex
} }
public override string Name => "NZBVortex"; public override string Name => "NZBVortex";
public override bool SupportsCategories => true;
protected List<NzbVortexGroup> GetGroups() protected List<NzbVortexGroup> GetGroups()
{ {
@ -111,19 +113,27 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex
private ValidationFailure TestCategory() private ValidationFailure TestCategory()
{ {
var group = GetGroups().FirstOrDefault(c => c.GroupName == Settings.Category); var groups = GetGroups();
if (group == null) foreach (var category in Categories)
{ {
if (Settings.Category.IsNotNullOrWhiteSpace()) if (!category.ClientCategory.IsNullOrWhiteSpace() && !groups.Any(v => v.GroupName == category.ClientCategory))
{ {
return new NzbDroneValidationFailure("Category", "Group does not exist") return new NzbDroneValidationFailure(string.Empty, "Group does not exist")
{ {
DetailedDescription = "The Group you entered doesn't exist in NzbVortex. Go to NzbVortex to create it." DetailedDescription = "A mapped category you entered doesn't exist in NzbVortex. Go to NzbVortex to create it."
}; };
} }
} }
if (!Settings.Category.IsNullOrWhiteSpace() && !groups.Any(v => v.GroupName == Settings.Category))
{
return new NzbDroneValidationFailure("Category", "Category does not exist")
{
DetailedDescription = "The category you entered doesn't exist in NzbVortex. Go to NzbVortex to create it."
};
}
return null; return null;
} }

@ -13,7 +13,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex
{ {
public interface INzbVortexProxy public interface INzbVortexProxy
{ {
string DownloadNzb(byte[] nzbData, string filename, int priority, NzbVortexSettings settings); string DownloadNzb(byte[] nzbData, string filename, int priority, NzbVortexSettings settings, string group);
void Remove(int id, bool deleteData, NzbVortexSettings settings); void Remove(int id, bool deleteData, NzbVortexSettings settings);
NzbVortexVersionResponse GetVersion(NzbVortexSettings settings); NzbVortexVersionResponse GetVersion(NzbVortexSettings settings);
NzbVortexApiVersionResponse GetApiVersion(NzbVortexSettings settings); NzbVortexApiVersionResponse GetApiVersion(NzbVortexSettings settings);
@ -37,7 +37,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex
_authSessionIdCache = cacheManager.GetCache<string>(GetType(), "authCache"); _authSessionIdCache = cacheManager.GetCache<string>(GetType(), "authCache");
} }
public string DownloadNzb(byte[] nzbData, string filename, int priority, NzbVortexSettings settings) public string DownloadNzb(byte[] nzbData, string filename, int priority, NzbVortexSettings settings, string group)
{ {
var requestBuilder = BuildRequest(settings).Resource("nzb/add") var requestBuilder = BuildRequest(settings).Resource("nzb/add")
.Post() .Post()
@ -45,7 +45,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex
if (settings.Category.IsNotNullOrWhiteSpace()) if (settings.Category.IsNotNullOrWhiteSpace())
{ {
requestBuilder.AddQueryParam("groupname", settings.Category); requestBuilder.AddQueryParam("groupname", group);
} }
requestBuilder.AddFormUpload("name", filename, nzbData, "application/x-nzb"); requestBuilder.AddFormUpload("name", filename, nzbData, "application/x-nzb");

@ -47,7 +47,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex
[FieldDefinition(3, Label = "API Key", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)] [FieldDefinition(3, Label = "API Key", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey)]
public string ApiKey { get; set; } 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")] [FieldDefinition(4, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")]
public string Category { get; set; } public string Category { get; set; }
[FieldDefinition(5, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "Priority to use when grabbing items")] [FieldDefinition(5, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "Priority to use when grabbing items")]

@ -33,7 +33,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget
protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContent) protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContent)
{ {
var category = Settings.Category; var category = GetCategoryForRelease(release) ?? Settings.Category;
var priority = Settings.Priority; var priority = Settings.Priority;
@ -50,7 +50,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget
protected override string AddFromLink(ReleaseInfo release) protected override string AddFromLink(ReleaseInfo release)
{ {
var category = Settings.Category; var category = GetCategoryForRelease(release) ?? Settings.Category;
var priority = Settings.Priority; var priority = Settings.Priority;
@ -66,6 +66,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget
} }
public override string Name => "NZBGet"; public override string Name => "NZBGet";
public override bool SupportsCategories => true;
protected IEnumerable<NzbgetCategory> GetCategories(Dictionary<string, string> config) protected IEnumerable<NzbgetCategory> GetCategories(Dictionary<string, string> config)
{ {
@ -139,6 +140,18 @@ namespace NzbDrone.Core.Download.Clients.Nzbget
var config = _proxy.GetConfig(Settings); var config = _proxy.GetConfig(Settings);
var categories = GetCategories(config); var categories = GetCategories(config);
foreach (var category in Categories)
{
if (!category.ClientCategory.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == category.ClientCategory))
{
return new NzbDroneValidationFailure(string.Empty, "Category does not exist")
{
InfoLink = _proxy.GetBaseUrl(Settings),
DetailedDescription = "A mapped category you entered doesn't exist in NZBGet. Go to NZBGet to create it."
};
}
}
if (!Settings.Category.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == Settings.Category)) if (!Settings.Category.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == Settings.Category))
{ {
return new NzbDroneValidationFailure("Category", "Category does not exist") return new NzbDroneValidationFailure("Category", "Category does not exist")

@ -53,7 +53,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget
[FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } public string Password { get; set; }
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")]
public string Category { get; set; } public string Category { get; set; }
[FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority for items added from Prowlarr")] [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority for items added from Prowlarr")]

@ -23,6 +23,7 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic
} }
public override string Name => "Pneumatic"; public override string Name => "Pneumatic";
public override bool SupportsCategories => false;
public override DownloadProtocol Protocol => DownloadProtocol.Usenet; public override DownloadProtocol Protocol => DownloadProtocol.Usenet;

@ -52,8 +52,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
//var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1); //var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1);
var itemToTop = Settings.Priority == (int)QBittorrentPriority.First; var itemToTop = Settings.Priority == (int)QBittorrentPriority.First;
var forceStart = (QBittorrentState)Settings.InitialState == QBittorrentState.ForceStart; var forceStart = (QBittorrentState)Settings.InitialState == QBittorrentState.ForceStart;
var category = GetCategoryForRelease(release) ?? Settings.Category;
Proxy.AddTorrentFromUrl(magnetLink, null, Settings); Proxy.AddTorrentFromUrl(magnetLink, null, Settings, category);
if (itemToTop || forceStart) if (itemToTop || forceStart)
{ {
@ -100,8 +101,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
//var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1); //var addHasSetShareLimits = setShareLimits && ProxyApiVersion >= new Version(2, 8, 1);
var itemToTop = Settings.Priority == (int)QBittorrentPriority.First; var itemToTop = Settings.Priority == (int)QBittorrentPriority.First;
var forceStart = (QBittorrentState)Settings.InitialState == QBittorrentState.ForceStart; var forceStart = (QBittorrentState)Settings.InitialState == QBittorrentState.ForceStart;
var category = GetCategoryForRelease(release) ?? Settings.Category;
Proxy.AddTorrentFromFile(filename, fileContent, null, Settings); Proxy.AddTorrentFromFile(filename, fileContent, null, Settings, category);
if (itemToTop || forceStart) if (itemToTop || forceStart)
{ {
@ -167,6 +169,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
} }
public override string Name => "qBittorrent"; public override string Name => "qBittorrent";
public override bool SupportsCategories => true;
protected override void Test(List<ValidationFailure> failures) protected override void Test(List<ValidationFailure> failures)
{ {
@ -197,7 +200,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
else if (version < Version.Parse("1.6")) else if (version < Version.Parse("1.6"))
{ {
// API version 6 introduced support for labels // API version 6 introduced support for labels
if (Settings.Category.IsNotNullOrWhiteSpace()) if (Settings.Category.IsNotNullOrWhiteSpace() || Categories.Count > 0)
{ {
return new NzbDroneValidationFailure("Category", "Category is not supported") return new NzbDroneValidationFailure("Category", "Category is not supported")
{ {
@ -205,15 +208,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
}; };
} }
} }
else if (Settings.Category.IsNullOrWhiteSpace())
{
// warn if labels are supported, but category is not provided
return new NzbDroneValidationFailure("Category", "Category is recommended")
{
IsWarning = true,
DetailedDescription = "Prowlarr will not attempt to import completed downloads without a category."
};
}
} }
catch (DownloadClientAuthenticationException ex) catch (DownloadClientAuthenticationException ex)
{ {
@ -251,7 +245,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
private ValidationFailure TestCategory() private ValidationFailure TestCategory()
{ {
if (Settings.Category.IsNullOrWhiteSpace()) if (Settings.Category.IsNullOrWhiteSpace() && Categories.Count == 0)
{ {
return null; return null;
} }
@ -265,6 +259,23 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
Dictionary<string, QBittorrentLabel> labels = Proxy.GetLabels(Settings); Dictionary<string, QBittorrentLabel> labels = Proxy.GetLabels(Settings);
foreach (var category in Categories)
{
if (category.ClientCategory.IsNotNullOrWhiteSpace() && !labels.ContainsKey(category.ClientCategory))
{
Proxy.AddLabel(category.ClientCategory, Settings);
labels = Proxy.GetLabels(Settings);
if (!labels.ContainsKey(category.ClientCategory))
{
return new NzbDroneValidationFailure(string.Empty, "Configuration of label failed")
{
DetailedDescription = "Prowlarr was unable to add the label to qBittorrent."
};
}
}
}
if (Settings.Category.IsNotNullOrWhiteSpace() && !labels.ContainsKey(Settings.Category)) if (Settings.Category.IsNotNullOrWhiteSpace() && !labels.ContainsKey(Settings.Category))
{ {
Proxy.AddLabel(Settings.Category, Settings); Proxy.AddLabel(Settings.Category, Settings);

@ -18,8 +18,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings); QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings);
List<QBittorrentTorrentFile> GetTorrentFiles(string hash, QBittorrentSettings settings); List<QBittorrentTorrentFile> GetTorrentFiles(string hash, QBittorrentSettings settings);
void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings); void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings, string category);
void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings); void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings, string category);
void RemoveTorrent(string hash, bool removeData, QBittorrentSettings settings); void RemoveTorrent(string hash, bool removeData, QBittorrentSettings settings);
void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); void SetTorrentLabel(string hash, string label, QBittorrentSettings settings);

@ -113,15 +113,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
return response; return response;
} }
public void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) public void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings, string category)
{ {
var request = BuildRequest(settings).Resource("/command/download") var request = BuildRequest(settings).Resource("/command/download")
.Post() .Post()
.AddFormParameter("urls", torrentUrl); .AddFormParameter("urls", torrentUrl);
if (settings.Category.IsNotNullOrWhiteSpace()) if (category.IsNotNullOrWhiteSpace())
{ {
request.AddFormParameter("category", settings.Category); request.AddFormParameter("category", category);
} }
// Note: ForceStart is handled by separate api call // Note: ForceStart is handled by separate api call
@ -143,15 +143,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
} }
} }
public void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) public void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings, string category)
{ {
var request = BuildRequest(settings).Resource("/command/upload") var request = BuildRequest(settings).Resource("/command/upload")
.Post() .Post()
.AddFormUpload("torrents", fileName, fileContent); .AddFormUpload("torrents", fileName, fileContent);
if (settings.Category.IsNotNullOrWhiteSpace()) if (category.IsNotNullOrWhiteSpace())
{ {
request.AddFormParameter("category", settings.Category); request.AddFormParameter("category", category);
} }
// Note: ForceStart is handled by separate api call // Note: ForceStart is handled by separate api call

@ -119,14 +119,14 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
return response; return response;
} }
public void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) public void AddTorrentFromUrl(string torrentUrl, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings, string category)
{ {
var request = BuildRequest(settings).Resource("/api/v2/torrents/add") var request = BuildRequest(settings).Resource("/api/v2/torrents/add")
.Post() .Post()
.AddFormParameter("urls", torrentUrl); .AddFormParameter("urls", torrentUrl);
if (settings.Category.IsNotNullOrWhiteSpace()) if (category.IsNotNullOrWhiteSpace())
{ {
request.AddFormParameter("category", settings.Category); request.AddFormParameter("category", category);
} }
// Note: ForceStart is handled by separate api call // Note: ForceStart is handled by separate api call
@ -153,15 +153,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
} }
} }
public void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) public void AddTorrentFromFile(string fileName, byte[] fileContent, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings, string category)
{ {
var request = BuildRequest(settings).Resource("/api/v2/torrents/add") var request = BuildRequest(settings).Resource("/api/v2/torrents/add")
.Post() .Post()
.AddFormUpload("torrents", fileName, fileContent); .AddFormUpload("torrents", fileName, fileContent);
if (settings.Category.IsNotNullOrWhiteSpace()) if (category.IsNotNullOrWhiteSpace())
{ {
request.AddFormParameter("category", settings.Category); request.AddFormParameter("category", category);
} }
// Note: ForceStart is handled by separate api call // Note: ForceStart is handled by separate api call

@ -47,7 +47,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
[FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } public string Password { get; set; }
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")]
public string Category { get; set; } public string Category { get; set; }
[FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing items")] [FieldDefinition(7, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing items")]

@ -33,7 +33,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContent) protected override string AddFromNzbFile(ReleaseInfo release, string filename, byte[] fileContent)
{ {
var category = Settings.Category; var category = GetCategoryForRelease(release) ?? Settings.Category;
var priority = Settings.Priority; var priority = Settings.Priority;
var response = _proxy.DownloadNzb(fileContent, filename, category, priority, Settings); var response = _proxy.DownloadNzb(fileContent, filename, category, priority, Settings);
@ -48,7 +48,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
protected override string AddFromLink(ReleaseInfo release) protected override string AddFromLink(ReleaseInfo release)
{ {
var category = Settings.Category; var category = GetCategoryForRelease(release) ?? Settings.Category;
var priority = Settings.Priority; var priority = Settings.Priority;
var response = _proxy.DownloadNzbByUrl(release.DownloadUrl, category, priority, Settings); var response = _proxy.DownloadNzbByUrl(release.DownloadUrl, category, priority, Settings);
@ -62,6 +62,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
} }
public override string Name => "SABnzbd"; public override string Name => "SABnzbd";
public override bool SupportsCategories => true;
protected IEnumerable<SabnzbdCategory> GetCategories(SabnzbdConfig config) protected IEnumerable<SabnzbdCategory> GetCategories(SabnzbdConfig config)
{ {
@ -260,29 +261,27 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
private ValidationFailure TestCategory() private ValidationFailure TestCategory()
{ {
var config = _proxy.GetConfig(Settings); var config = _proxy.GetConfig(Settings);
var category = GetCategories(config).FirstOrDefault((SabnzbdCategory v) => v.Name == Settings.Category); var categories = GetCategories(config);
if (category != null) foreach (var category in Categories)
{ {
if (category.Dir.EndsWith("*")) if (!category.ClientCategory.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == category.ClientCategory))
{ {
return new NzbDroneValidationFailure("Category", "Enable Job folders") return new NzbDroneValidationFailure(string.Empty, "Category does not exist")
{ {
InfoLink = _proxy.GetBaseUrl(Settings, "config/categories/"), InfoLink = _proxy.GetBaseUrl(Settings),
DetailedDescription = "Prowlarr prefers each download to have a separate folder. With * appended to the Folder/Path SABnzbd will not create these job folders. Go to SABnzbd to fix it." DetailedDescription = "A mapped category you entered doesn't exist in Sabnzbd. Go to Sabnzbd to create it."
}; };
} }
} }
else
if (!Settings.Category.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == Settings.Category))
{ {
if (!Settings.Category.IsNullOrWhiteSpace()) return new NzbDroneValidationFailure("Category", "Category does not exist")
{ {
return new NzbDroneValidationFailure("Category", "Category does not exist") InfoLink = _proxy.GetBaseUrl(Settings),
{ DetailedDescription = "The category you entered doesn't exist in Sabnzbd. Go to Sabnzbd to create it."
InfoLink = _proxy.GetBaseUrl(Settings, "config/categories/"), };
DetailedDescription = "The category you entered doesn't exist in SABnzbd. Go to SABnzbd to create it."
};
}
} }
if (config.Misc.enable_tv_sorting && ContainsCategory(config.Misc.tv_categories, Settings.Category)) if (config.Misc.enable_tv_sorting && ContainsCategory(config.Misc.tv_categories, Settings.Category))

@ -65,7 +65,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd
[FieldDefinition(6, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(6, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } public string Password { get; set; }
[FieldDefinition(7, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] [FieldDefinition(7, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")]
public string Category { get; set; } public string Category { get; set; }
[FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing items")] [FieldDefinition(8, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing items")]

@ -38,5 +38,6 @@ namespace NzbDrone.Core.Download.Clients.Transmission
} }
public override string Name => "Transmission"; public override string Name => "Transmission";
public override bool SupportsCategories => false;
} }
} }

@ -53,7 +53,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
[FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } public string Password { get; set; }
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")]
public string Category { get; set; } public string Category { get; set; }
[FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default Transmission location")] [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default Transmission location")]

@ -58,5 +58,6 @@ namespace NzbDrone.Core.Download.Clients.Vuze
} }
public override string Name => "Vuze"; public override string Name => "Vuze";
public override bool SupportsCategories => false;
} }
} }

@ -38,7 +38,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent
{ {
var priority = (RTorrentPriority)Settings.Priority; var priority = (RTorrentPriority)Settings.Priority;
_proxy.AddTorrentFromUrl(magnetLink, Settings.Category, priority, Settings.Directory, Settings); _proxy.AddTorrentFromUrl(magnetLink, GetCategoryForRelease(release) ?? Settings.Category, priority, Settings.Directory, Settings);
var tries = 10; var tries = 10;
var retryDelay = 500; var retryDelay = 500;
@ -58,7 +58,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent
{ {
var priority = (RTorrentPriority)Settings.Priority; var priority = (RTorrentPriority)Settings.Priority;
_proxy.AddTorrentFromFile(filename, fileContent, Settings.Category, priority, Settings.Directory, Settings); _proxy.AddTorrentFromFile(filename, fileContent, GetCategoryForRelease(release) ?? Settings.Category, priority, Settings.Directory, Settings);
var tries = 10; var tries = 10;
var retryDelay = 500; var retryDelay = 500;
@ -73,6 +73,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent
} }
public override string Name => "rTorrent"; public override string Name => "rTorrent";
public override bool SupportsCategories => true;
public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to remove torrents that have finished seeding when using rTorrent", ProviderMessageType.Warning); public override ProviderMessage Message => new ProviderMessage("Prowlarr is unable to remove torrents that have finished seeding when using rTorrent", ProviderMessageType.Warning);

@ -48,7 +48,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent
[FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } public string Password { get; set; }
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional.")] [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional.")]
public string Category { get; set; } public string Category { get; set; }
[FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default rTorrent location")] [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default rTorrent location")]

@ -38,9 +38,10 @@ namespace NzbDrone.Core.Download.Clients.UTorrent
_proxy.AddTorrentFromUrl(magnetLink, Settings); _proxy.AddTorrentFromUrl(magnetLink, Settings);
//_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings);
if (Settings.Category.IsNotNullOrWhiteSpace()) var category = GetCategoryForRelease(release) ?? Settings.Category;
if (GetCategoryForRelease(release).IsNotNullOrWhiteSpace())
{ {
_proxy.SetTorrentLabel(hash, Settings.Category, Settings); _proxy.SetTorrentLabel(hash, category, Settings);
} }
if (Settings.Priority == (int)UTorrentPriority.First) if (Settings.Priority == (int)UTorrentPriority.First)
@ -58,9 +59,10 @@ namespace NzbDrone.Core.Download.Clients.UTorrent
_proxy.AddTorrentFromFile(filename, fileContent, Settings); _proxy.AddTorrentFromFile(filename, fileContent, Settings);
//_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings); //_proxy.SetTorrentSeedingConfiguration(hash, release.SeedConfiguration, Settings);
if (Settings.Category.IsNotNullOrWhiteSpace()) var category = GetCategoryForRelease(release) ?? Settings.Category;
if (category.IsNotNullOrWhiteSpace())
{ {
_proxy.SetTorrentLabel(hash, Settings.Category, Settings); _proxy.SetTorrentLabel(hash, category, Settings);
} }
if (Settings.Priority == (int)UTorrentPriority.First) if (Settings.Priority == (int)UTorrentPriority.First)
@ -74,40 +76,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent
} }
public override string Name => "uTorrent"; public override string Name => "uTorrent";
public override bool SupportsCategories => true;
private List<UTorrentTorrent> GetTorrents()
{
List<UTorrentTorrent> torrents;
var cacheKey = string.Format("{0}:{1}:{2}", Settings.Host, Settings.Port, Settings.Category);
var cache = _torrentCache.Find(cacheKey);
var response = _proxy.GetTorrents(cache == null ? null : cache.CacheID, Settings);
if (cache != null && response.Torrents == null)
{
var removedAndUpdated = new HashSet<string>(response.TorrentsChanged.Select(v => v.Hash).Concat(response.TorrentsRemoved));
torrents = cache.Torrents
.Where(v => !removedAndUpdated.Contains(v.Hash))
.Concat(response.TorrentsChanged)
.ToList();
}
else
{
torrents = response.Torrents;
}
cache = new UTorrentTorrentCache
{
CacheID = response.CacheNumber,
Torrents = torrents
};
_torrentCache.Set(cacheKey, cache, TimeSpan.FromMinutes(15));
return torrents;
}
protected override void Test(List<ValidationFailure> failures) protected override void Test(List<ValidationFailure> failures)
{ {

@ -46,7 +46,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent
[FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)] [FieldDefinition(5, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; } public string Password { get; set; }
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")] [FieldDefinition(6, Label = "Default Category", Type = FieldType.Textbox, HelpText = "Default fallback category if no mapped category exists for a release. Adding a category specific to Prowlarr avoids conflicts with unrelated downloads, but it's optional")]
public string Category { get; set; } public string Category { get; set; }
[FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing items")] [FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing items")]

@ -1,14 +1,17 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentValidation.Results; using FluentValidation.Results;
using NLog; using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
using Org.BouncyCastle.Crypto.Tls;
namespace NzbDrone.Core.Download namespace NzbDrone.Core.Download
{ {
@ -50,6 +53,9 @@ namespace NzbDrone.Core.Download
return GetType().Name; return GetType().Name;
} }
protected List<DownloadClientCategory> Categories => ((DownloadClientDefinition)Definition).Categories;
public abstract bool SupportsCategories { get; }
public abstract DownloadProtocol Protocol public abstract DownloadProtocol Protocol
{ {
get; get;
@ -57,12 +63,54 @@ namespace NzbDrone.Core.Download
public abstract Task<string> Download(ReleaseInfo release, bool redirect, IIndexer indexer); public abstract Task<string> Download(ReleaseInfo release, bool redirect, IIndexer indexer);
protected string GetCategoryForRelease(ReleaseInfo release)
{
var categories = ((DownloadClientDefinition)Definition).Categories;
if (categories.Count == 0)
{
return null;
}
// Check for direct mapping
var category = categories.FirstOrDefault(x => x.Categories.Intersect(release.Categories.Select(c => c.Id)).Any())?.ClientCategory;
// Check for parent mapping
if (category == null)
{
foreach (var cat in categories)
{
var mappedCat = NewznabStandardCategory.AllCats.Where(x => cat.Categories.Contains(x.Id));
var subCats = mappedCat.SelectMany(x => x.SubCategories);
if (subCats.Intersect(release.Categories).Any())
{
category = cat.ClientCategory;
break;
}
}
}
return category;
}
protected virtual void ValidateCategories(List<ValidationFailure> failures)
{
foreach (var category in ((DownloadClientDefinition)Definition).Categories)
{
if (category.ClientCategory.IsNullOrWhiteSpace())
{
failures.AddIfNotNull(new ValidationFailure(string.Empty, "Category can not be empty"));
}
}
}
public ValidationResult Test() public ValidationResult Test()
{ {
var failures = new List<ValidationFailure>(); var failures = new List<ValidationFailure>();
try try
{ {
ValidateCategories(failures);
Test(failures); Test(failures);
} }
catch (Exception ex) catch (Exception ex)
@ -97,5 +145,17 @@ namespace NzbDrone.Core.Download
return null; return null;
} }
private bool HasConcreteImplementation(string methodName)
{
var method = GetType().GetMethod(methodName);
if (method == null)
{
throw new MissingMethodException(GetType().Name, Name);
}
return !method.DeclaringType.IsAbstract;
}
} }
} }

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NzbDrone.Core.Download
{
public class DownloadClientCategory
{
public string ClientCategory { get; set; }
public List<int> Categories { get; set; }
}
}

@ -1,10 +1,18 @@
using NzbDrone.Core.Indexers; using System.Collections.Generic;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.Download namespace NzbDrone.Core.Download
{ {
public class DownloadClientDefinition : ProviderDefinition public class DownloadClientDefinition : ProviderDefinition
{ {
public DownloadClientDefinition()
{
Categories = new List<DownloadClientCategory>();
}
public List<DownloadClientCategory> Categories { get; set; }
public bool SupportsCategories { get; set; }
public DownloadProtocol Protocol { get; set; } public DownloadProtocol Protocol { get; set; }
public int Priority { get; set; } = 1; public int Priority { get; set; } = 1;
} }

@ -40,6 +40,7 @@ namespace NzbDrone.Core.Download
base.SetProviderCharacteristics(provider, definition); base.SetProviderCharacteristics(provider, definition);
definition.Protocol = provider.Protocol; definition.Protocol = provider.Protocol;
definition.SupportsCategories = provider.SupportsCategories;
} }
public List<IDownloadClient> DownloadHandlingEnabled(bool filterBlockedClients = true) public List<IDownloadClient> DownloadHandlingEnabled(bool filterBlockedClients = true)

@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
@ -7,6 +8,7 @@ namespace NzbDrone.Core.Download
{ {
public interface IDownloadClient : IProvider public interface IDownloadClient : IProvider
{ {
bool SupportsCategories { get; }
DownloadProtocol Protocol { get; } DownloadProtocol Protocol { get; }
Task<string> Download(ReleaseInfo release, bool redirect, IIndexer indexer); Task<string> Download(ReleaseInfo release, bool redirect, IIndexer indexer);
} }

@ -39,6 +39,7 @@
"AppProfileSelectHelpText": "App profiles are used to control RSS, Automatic Search and Interactive Search settings on application sync", "AppProfileSelectHelpText": "App profiles are used to control RSS, Automatic Search and Interactive Search settings on application sync",
"Apps": "Apps", "Apps": "Apps",
"AppSettingsSummary": "Applications and settings to configure how Prowlarr interacts with your PVR programs", "AppSettingsSummary": "Applications and settings to configure how Prowlarr interacts with your PVR programs",
"AreYouSureYouWantToDeleteCategory": "Are you sure you want to delete mapped category?",
"AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?", "AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?",
"AudioSearch": "Audio Search", "AudioSearch": "Audio Search",
"Auth": "Auth", "Auth": "Auth",
@ -97,6 +98,7 @@
"DeleteAppProfile": "Delete App Profile", "DeleteAppProfile": "Delete App Profile",
"DeleteBackup": "Delete Backup", "DeleteBackup": "Delete Backup",
"DeleteBackupMessageText": "Are you sure you want to delete the backup '{0}'?", "DeleteBackupMessageText": "Are you sure you want to delete the backup '{0}'?",
"DeleteClientCategory": "Delete Download Client Category",
"DeleteDownloadClient": "Delete Download Client", "DeleteDownloadClient": "Delete Download Client",
"DeleteDownloadClientMessageText": "Are you sure you want to delete the download client '{0}'?", "DeleteDownloadClientMessageText": "Are you sure you want to delete the download client '{0}'?",
"DeleteIndexerProxy": "Delete Indexer Proxy", "DeleteIndexerProxy": "Delete Indexer Proxy",
@ -113,6 +115,7 @@
"Docker": "Docker", "Docker": "Docker",
"Donations": "Donations", "Donations": "Donations",
"DownloadClient": "Download Client", "DownloadClient": "Download Client",
"DownloadClientCategory": "Download Client Category",
"DownloadClients": "Download Clients", "DownloadClients": "Download Clients",
"DownloadClientSettings": "Download Client Settings", "DownloadClientSettings": "Download Client Settings",
"DownloadClientsSettingsSummary": "Download clients configuration for integration into Prowlarr UI search", "DownloadClientsSettingsSummary": "Download clients configuration for integration into Prowlarr UI search",
@ -226,6 +229,7 @@
"Logs": "Logs", "Logs": "Logs",
"MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details", "MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details",
"Manual": "Manual", "Manual": "Manual",
"MappedCategories": "Mapped Categories",
"MappedDrivesRunningAsService": "Mapped network drives are not available when running as a Windows Service. Please see the FAQ for more information", "MappedDrivesRunningAsService": "Mapped network drives are not available when running as a Windows Service. Please see the FAQ for more information",
"MassEditor": "Mass Editor", "MassEditor": "Mass Editor",
"Mechanism": "Mechanism", "Mechanism": "Mechanism",

@ -1,3 +1,4 @@
using System.Collections.Generic;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
@ -8,6 +9,8 @@ namespace Prowlarr.Api.V1.DownloadClient
public bool Enable { get; set; } public bool Enable { get; set; }
public DownloadProtocol Protocol { get; set; } public DownloadProtocol Protocol { get; set; }
public int Priority { get; set; } public int Priority { get; set; }
public List<DownloadClientCategory> Categories { get; set; }
public bool SupportsCategories { get; set; }
} }
public class DownloadClientResourceMapper : ProviderResourceMapper<DownloadClientResource, DownloadClientDefinition> public class DownloadClientResourceMapper : ProviderResourceMapper<DownloadClientResource, DownloadClientDefinition>
@ -24,6 +27,8 @@ namespace Prowlarr.Api.V1.DownloadClient
resource.Enable = definition.Enable; resource.Enable = definition.Enable;
resource.Protocol = definition.Protocol; resource.Protocol = definition.Protocol;
resource.Priority = definition.Priority; resource.Priority = definition.Priority;
resource.Categories = definition.Categories;
resource.SupportsCategories = definition.SupportsCategories;
return resource; return resource;
} }
@ -40,6 +45,7 @@ namespace Prowlarr.Api.V1.DownloadClient
definition.Enable = resource.Enable; definition.Enable = resource.Enable;
definition.Protocol = resource.Protocol; definition.Protocol = resource.Protocol;
definition.Priority = resource.Priority; definition.Priority = resource.Priority;
definition.Categories = resource.Categories;
return definition; return definition;
} }

Loading…
Cancel
Save