From eb697b630e9963a64a681c5391df245bee0fc752 Mon Sep 17 00:00:00 2001 From: Qstick Date: Fri, 20 Nov 2020 15:55:26 -0500 Subject: [PATCH] Category Selection on Search Page --- .../Components/Form/DeviceInputConnector.js | 33 +--- .../Components/Form/EnhancedSelectInput.css | 19 +++ .../Components/Form/EnhancedSelectInput.js | 157 ++++++++++++----- .../Form/EnhancedSelectInputConnector.js | 159 ++++++++++++++++++ .../Form/EnhancedSelectInputOption.js | 8 + .../src/Components/Form/FormInputGroup.js | 4 + .../Form/HintedSelectInputOption.js | 3 + .../NewznabCategorySelectInputConnector.js | 71 ++++++++ .../Components/Form/ProviderFieldFormGroup.js | 14 +- frontend/src/Components/Page/PageConnector.js | 15 +- frontend/src/Search/SearchFooter.js | 24 ++- frontend/src/Search/SearchIndex.js | 4 +- .../Actions/Settings/indexerCategories.js | 48 ++++++ .../Store/Actions/providerOptionActions.js | 78 ++++++--- frontend/src/Store/Actions/settingsActions.js | 5 + .../Annotations/FieldDefinitionAttribute.cs | 11 +- .../Newznab/NewznabCapabilitiesProvider.cs | 1 + .../Newznab/NewznabRequestGenerator.cs | 11 +- src/NzbDrone.Core/Indexers/HttpIndexerBase.cs | 9 +- .../PushBullet/PushBulletSettings.cs | 2 +- .../IndexerDefaultCategoriesModule.cs | 19 +++ src/Prowlarr.Api.V1/ProviderModuleBase.cs | 2 +- src/Prowlarr.Api.V1/Search/SearchModule.cs | 8 +- src/Prowlarr.Http/ClientSchema/Field.cs | 3 +- .../ClientSchema/SchemaBuilder.cs | 12 +- src/Prowlarr.Http/REST/RestModule.cs | 10 +- 26 files changed, 606 insertions(+), 124 deletions(-) create mode 100644 frontend/src/Components/Form/EnhancedSelectInputConnector.js create mode 100644 frontend/src/Components/Form/NewznabCategorySelectInputConnector.js create mode 100644 frontend/src/Store/Actions/Settings/indexerCategories.js create mode 100644 src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesModule.cs diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js index 2053d3a30..4e3281169 100644 --- a/frontend/src/Components/Form/DeviceInputConnector.js +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -2,34 +2,22 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { clearOptions, fetchOptions } from 'Store/Actions/providerOptionActions'; +import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; import DeviceInput from './DeviceInput'; function createMapStateToProps() { return createSelector( (state, { value }) => value, - (state, { name }) => name, - (state) => state.providerOptions, - (value, name, devices) => { - const { - isFetching, - isPopulated, - error, - items - } = devices; + (state) => state.providerOptions.devices || defaultState, + (value, devices) => { return { - isFetching, - isPopulated, - error, - items: items[name] || [], + ...devices, selectedDevices: value.map((valueDevice) => { - const sectionItems = items[name] || []; - // Disable equality ESLint rule so we don't need to worry about // a type mismatch between the value items and the device ID. // eslint-disable-next-line eqeqeq - const device = sectionItems.find((d) => d.id == valueDevice); + const device = devices.items.find((d) => d.id == valueDevice); if (device) { return { @@ -63,7 +51,7 @@ class DeviceInputConnector extends Component { } componentWillUnmount = () => { - this.props.dispatchClearOptions(); + this.props.dispatchClearOptions({ section: 'devices' }); } // @@ -73,14 +61,12 @@ class DeviceInputConnector extends Component { const { provider, providerData, - dispatchFetchOptions, - requestAction, - name + dispatchFetchOptions } = this.props; dispatchFetchOptions({ - action: requestAction, - itemSection: name, + section: 'devices', + action: 'getDevices', provider, providerData }); @@ -109,7 +95,6 @@ class DeviceInputConnector extends Component { DeviceInputConnector.propTypes = { provider: PropTypes.string.isRequired, providerData: PropTypes.object.isRequired, - requestAction: PropTypes.string.isRequired, name: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, dispatchFetchOptions: PropTypes.func.isRequired, diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css index c3623c199..60cd28d69 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -5,6 +5,10 @@ align-items: center; } +.editableContainer { + width: 100%; +} + .hasError { composes: hasError from '~Components/Form/Input.css'; } @@ -22,6 +26,16 @@ margin-left: 12px; } +.dropdownArrowContainerEditable { + position: absolute; + top: 0; + right: 0; + padding-right: 17px; + width: 30%; + height: 35px; + text-align: right; +} + .dropdownArrowContainerDisabled { composes: dropdownArrowContainer; @@ -66,3 +80,8 @@ border-radius: 4px; background-color: $white; } + +.loading { + display: inline-block; + margin: 5px -5px 5px 0; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index 08690161d..197375bb6 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -5,6 +5,7 @@ import React, { Component } from 'react'; import { Manager, Popper, Reference } from 'react-popper'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Measure from 'Components/Measure'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; @@ -16,6 +17,7 @@ import getUniqueElememtId from 'Utilities/getUniqueElementId'; import { isMobile as isMobileUtil } from 'Utilities/mobile'; import HintedSelectInputOption from './HintedSelectInputOption'; import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; +import TextInput from './TextInput'; import styles from './EnhancedSelectInput.css'; function isArrowKey(keyCode) { @@ -168,11 +170,21 @@ class EnhancedSelectInput extends Component { } } + onFocus = () => { + if (this.state.isOpen) { + this._removeListener(); + this.setState({ isOpen: false }); + } + } + onBlur = () => { - // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) - const origIndex = getSelectedIndex(this.props); - if (origIndex !== this.state.selectedIndex) { - this.setState({ selectedIndex: origIndex }); + if (!this.props.isEditable) { + // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) + const origIndex = getSelectedIndex(this.props); + + if (origIndex !== this.state.selectedIndex) { + this.setState({ selectedIndex: origIndex }); + } } } @@ -250,6 +262,10 @@ class EnhancedSelectInput extends Component { this._addListener(); } + if (!this.state.isOpen && this.props.onOpen) { + this.props.onOpen(); + } + this.setState({ isOpen: !this.state.isOpen }); } @@ -292,15 +308,19 @@ class EnhancedSelectInput extends Component { const { className, disabledClassName, + name, value, values, isDisabled, + isEditable, + isFetching, hasError, hasWarning, valueOptions, selectedValueOptions, selectedValueComponent: SelectedValueComponent, - optionComponent: OptionComponent + optionComponent: OptionComponent, + onChange } = this.props; const { @@ -326,40 +346,94 @@ class EnhancedSelectInput extends Component { whitelist={['width']} onMeasure={this.onMeasure} > - - - {selectedOption ? selectedOption.value : null} - - -
- -
- + { + isEditable ? +
+ + + { + isFetching && + + } + + { + !isFetching && + + } + +
: + + + {selectedOption ? selectedOption.value : null} + + +
+ + { + isFetching && + + } + + { + !isFetching && + + } +
+ + } )} @@ -483,12 +557,15 @@ EnhancedSelectInput.propTypes = { value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isEditable: PropTypes.bool.isRequired, hasError: PropTypes.bool, hasWarning: PropTypes.bool, valueOptions: PropTypes.object.isRequired, selectedValueOptions: PropTypes.object.isRequired, selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, optionComponent: PropTypes.elementType, + onOpen: PropTypes.func, onChange: PropTypes.func.isRequired }; @@ -496,6 +573,8 @@ EnhancedSelectInput.defaultProps = { className: styles.enhancedSelect, disabledClassName: styles.isDisabled, isDisabled: false, + isFetching: false, + isEditable: false, valueOptions: {}, selectedValueOptions: {}, selectedValueComponent: HintedSelectInputSelectedValue, diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js new file mode 100644 index 000000000..0311a920c --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputConnector.js @@ -0,0 +1,159 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +const importantFieldNames = [ + 'baseUrl', + 'apiPath', + 'apiKey' +]; + +function getProviderDataKey(providerData) { + if (!providerData || !providerData.fields) { + return null; + } + + const fields = providerData.fields + .filter((f) => importantFieldNames.includes(f.name)) + .map((f) => f.value); + + return fields; +} + +function getSelectOptions(items) { + if (!items) { + return []; + } + + return items.map((option) => { + return { + key: option.value, + value: option.name, + hint: option.hint, + parentKey: option.parentValue + }; + }); +} + +function createMapStateToProps() { + return createSelector( + (state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState, + (options) => { + if (options) { + return { + isFetching: options.isFetching, + values: getSelectOptions(options.items) + }; + } + } + ); +} + +const mapDispatchToProps = { + dispatchFetchOptions: fetchOptions, + dispatchClearOptions: clearOptions +}; + +class EnhancedSelectInputConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + refetchRequired: false + }; + } + + componentDidMount = () => { + this._populate(); + } + + componentDidUpdate = (prevProps) => { + const prevKey = getProviderDataKey(prevProps.providerData); + const nextKey = getProviderDataKey(this.props.providerData); + + if (!_.isEqual(prevKey, nextKey)) { + this.setState({ refetchRequired: true }); + } + } + + componentWillUnmount = () => { + this._cleanup(); + } + + // + // Listeners + + onOpen = () => { + if (this.state.refetchRequired) { + this._populate(); + } + } + + // + // Control + + _populate() { + const { + provider, + providerData, + selectOptionsProviderAction, + dispatchFetchOptions + } = this.props; + + if (selectOptionsProviderAction) { + this.setState({ refetchRequired: false }); + dispatchFetchOptions({ + section: selectOptionsProviderAction, + action: selectOptionsProviderAction, + provider, + providerData + }); + } + } + + _cleanup() { + const { + selectOptionsProviderAction, + dispatchClearOptions + } = this.props; + + if (selectOptionsProviderAction) { + dispatchClearOptions({ section: selectOptionsProviderAction }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +EnhancedSelectInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + selectOptionsProviderAction: PropTypes.string, + onChange: PropTypes.func.isRequired, + isFetching: PropTypes.bool.isRequired, + dispatchFetchOptions: PropTypes.func.isRequired, + dispatchClearOptions: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js index f594a4335..fce64d2f7 100644 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.js +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js @@ -32,6 +32,7 @@ class EnhancedSelectInputOption extends Component { const { className, id, + depth, isSelected, isDisabled, isHidden, @@ -54,6 +55,11 @@ class EnhancedSelectInputOption extends Component { onPress={this.onPress} > + { + depth !== 0 && +
+ } + { isMultiSelect && value, + (state) => state.settings.indexerCategories, + (value, categories) => { + const values = []; + + categories.items.forEach((element) => { + values.push({ + key: element.id, + value: element.name, + hint: `(${element.id})` + }); + + if (element.subCategories && element.subCategories.length > 0) { + element.subCategories.forEach((subCat) => { + values.push({ + key: subCat.id, + value: subCat.name, + hint: `(${subCat.id})`, + parentKey: element.id + }); + }); + } + }); + + console.log(values); + + return { + value, + values + }; + } + ); +} + +class IndexersSelectInputConnector extends Component { + + onChange = ({ name, value }) => { + this.props.onChange({ name, value }); + } + + // + // Render + + render() { + + return ( + + ); + } +} + +IndexersSelectInputConnector.propTypes = { + name: PropTypes.string.isRequired, + indexerIds: PropTypes.number, + value: PropTypes.arrayOf(PropTypes.number).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + onChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps)(IndexersSelectInputConnector); diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index bfbe1e763..f5130d18b 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -6,7 +6,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; import { inputTypes } from 'Helpers/Props'; -function getType(type, value) { +function getType({ type, selectOptionsProviderAction }) { switch (type) { case 'captcha': return inputTypes.CAPTCHA; @@ -23,6 +23,9 @@ function getType(type, value) { case 'filePath': return inputTypes.PATH; case 'select': + if (selectOptionsProviderAction) { + return inputTypes.DYNAMIC_SELECT; + } return inputTypes.SELECT; case 'tag': return inputTypes.TEXT_TAG; @@ -63,7 +66,6 @@ function ProviderFieldFormGroup(props) { value, type, advanced, - requestAction, hidden, pending, errors, @@ -88,7 +90,7 @@ function ProviderFieldFormGroup(props) { {label} @@ -109,7 +110,8 @@ function ProviderFieldFormGroup(props) { const selectOptionsShape = { name: PropTypes.string.isRequired, - value: PropTypes.number.isRequired + value: PropTypes.number.isRequired, + hint: PropTypes.string }; ProviderFieldFormGroup.propTypes = { @@ -121,12 +123,12 @@ ProviderFieldFormGroup.propTypes = { value: PropTypes.any, type: PropTypes.string.isRequired, advanced: PropTypes.bool.isRequired, - requestAction: PropTypes.string, hidden: PropTypes.string, pending: PropTypes.bool.isRequired, errors: PropTypes.arrayOf(PropTypes.object).isRequired, warnings: PropTypes.arrayOf(PropTypes.object).isRequired, selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)), + selectOptionsProviderAction: PropTypes.string, onChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index baf2a21dd..889f559af 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,7 +6,7 @@ import { createSelector } from 'reselect'; import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchIndexers } from 'Store/Actions/indexerActions'; -import { fetchIndexerFlags, fetchLanguages, fetchUISettings } from 'Store/Actions/settingsActions'; +import { fetchIndexerCategories, fetchIndexerFlags, fetchLanguages, fetchUISettings } from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; @@ -48,6 +48,7 @@ const selectIsPopulated = createSelector( (state) => state.settings.ui.isPopulated, (state) => state.settings.languages.isPopulated, (state) => state.indexers.isPopulated, + (state) => state.settings.indexerCategories.isPopulated, (state) => state.settings.indexerFlags.isPopulated, (state) => state.system.status.isPopulated, ( @@ -56,6 +57,7 @@ const selectIsPopulated = createSelector( uiSettingsIsPopulated, languagesIsPopulated, indexersIsPopulated, + indexerCategoriesIsPopulated, indexerFlagsIsPopulated, systemStatusIsPopulated ) => { @@ -65,6 +67,7 @@ const selectIsPopulated = createSelector( uiSettingsIsPopulated && languagesIsPopulated && indexersIsPopulated && + indexerCategoriesIsPopulated && indexerFlagsIsPopulated && systemStatusIsPopulated ); @@ -77,6 +80,7 @@ const selectErrors = createSelector( (state) => state.settings.ui.error, (state) => state.settings.languages.error, (state) => state.indexers.error, + (state) => state.settings.indexerCategories.error, (state) => state.settings.indexerFlags.error, (state) => state.system.status.error, ( @@ -85,6 +89,7 @@ const selectErrors = createSelector( uiSettingsError, languagesError, indexersError, + indexerCategoriesError, indexerFlagsError, systemStatusError ) => { @@ -94,6 +99,7 @@ const selectErrors = createSelector( uiSettingsError || languagesError || indexersError || + indexerCategoriesError || indexerFlagsError || systemStatusError ); @@ -105,6 +111,7 @@ const selectErrors = createSelector( uiSettingsError, languagesError, indexersError, + indexerCategoriesError, indexerFlagsError, systemStatusError }; @@ -150,6 +157,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchIndexers() { dispatch(fetchIndexers()); }, + dispatchFetchIndexerCategories() { + dispatch(fetchIndexerCategories()); + }, dispatchFetchIndexerFlags() { dispatch(fetchIndexerFlags()); }, @@ -187,6 +197,7 @@ class PageConnector extends Component { this.props.dispatchFetchTags(); this.props.dispatchFetchLanguages(); this.props.dispatchFetchIndexers(); + this.props.dispatchFetchIndexerCategories(); this.props.dispatchFetchIndexerFlags(); this.props.dispatchFetchUISettings(); this.props.dispatchFetchStatus(); @@ -210,6 +221,7 @@ class PageConnector extends Component { dispatchFetchTags, dispatchFetchLanguages, dispatchFetchIndexers, + dispatchFetchIndexerCategories, dispatchFetchIndexerFlags, dispatchFetchUISettings, dispatchFetchStatus, @@ -248,6 +260,7 @@ PageConnector.propTypes = { dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchLanguages: PropTypes.func.isRequired, dispatchFetchIndexers: PropTypes.func.isRequired, + dispatchFetchIndexerCategories: PropTypes.func.isRequired, dispatchFetchIndexerFlags: PropTypes.func.isRequired, dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired, diff --git a/frontend/src/Search/SearchFooter.js b/frontend/src/Search/SearchFooter.js index 2409ea6aa..a14a8aff4 100644 --- a/frontend/src/Search/SearchFooter.js +++ b/frontend/src/Search/SearchFooter.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import IndexersSelectInputConnector from 'Components/Form/IndexersSelectInputConnector'; +import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector'; import TextInput from 'Components/Form/TextInput'; import SpinnerButton from 'Components/Link/SpinnerButton'; import PageContentFooter from 'Components/Page/PageContentFooter'; @@ -18,7 +19,8 @@ class SearchFooter extends Component { this.state = { searchingReleases: false, searchQuery: '', - indexerIds: [] + indexerIds: [], + categories: [] }; } @@ -43,7 +45,7 @@ class SearchFooter extends Component { } onSearchPress = () => { - this.props.onSearchPress(this.state.searchQuery, this.state.indexerIds); + this.props.onSearchPress(this.state.searchQuery, this.state.indexerIds, this.state.categories); } // @@ -57,7 +59,8 @@ class SearchFooter extends Component { const { searchQuery, - indexerIds + indexerIds, + categories } = this.state; return ( @@ -84,13 +87,26 @@ class SearchFooter extends Component {
+
+ + + +
+
diff --git a/frontend/src/Search/SearchIndex.js b/frontend/src/Search/SearchIndex.js index 3775b46fa..6133ea202 100644 --- a/frontend/src/Search/SearchIndex.js +++ b/frontend/src/Search/SearchIndex.js @@ -146,8 +146,8 @@ class SearchIndex extends Component { this.setState({ jumpToCharacter }); } - onSearchPress = (query, indexerIds) => { - this.props.onSearchPress({ query, indexerIds }); + onSearchPress = (query, indexerIds, categories) => { + this.props.onSearchPress({ query, indexerIds, categories }); } onKeyUp = (event) => { diff --git a/frontend/src/Store/Actions/Settings/indexerCategories.js b/frontend/src/Store/Actions/Settings/indexerCategories.js new file mode 100644 index 000000000..2b07a4406 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexerCategories.js @@ -0,0 +1,48 @@ +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import { createThunk } from 'Store/thunks'; + +// +// Variables + +const section = 'settings.indexerCategories'; + +// +// Actions Types + +export const FETCH_INDEXER_CATEGORIES = 'settings/indexerFlags/fetchIndexerCategories'; + +// +// Action Creators + +export const fetchIndexerCategories = createThunk(FETCH_INDEXER_CATEGORIES); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXER_CATEGORIES]: createFetchHandler(section, '/indexer/categories') + }, + + // + // Reducers + + reducers: { + + } + +}; diff --git a/frontend/src/Store/Actions/providerOptionActions.js b/frontend/src/Store/Actions/providerOptionActions.js index 72a8b1745..4dc38a98f 100644 --- a/frontend/src/Store/Actions/providerOptionActions.js +++ b/frontend/src/Store/Actions/providerOptionActions.js @@ -1,3 +1,4 @@ +import _ from 'lodash'; import { createAction } from 'redux-actions'; import { createThunk, handleThunks } from 'Store/thunks'; import requestAction from 'Utilities/requestAction'; @@ -10,11 +11,14 @@ import createHandleActions from './Creators/createHandleActions'; export const section = 'providerOptions'; +const lastActions = {}; +let lastActionId = 0; + // // State export const defaultState = { - items: {}, + items: [], isFetching: false, isPopulated: false, error: false @@ -23,8 +27,8 @@ export const defaultState = { // // Actions Types -export const FETCH_OPTIONS = 'devices/fetchOptions'; -export const CLEAR_OPTIONS = 'devices/clearOptions'; +export const FETCH_OPTIONS = 'providers/fetchOptions'; +export const CLEAR_OPTIONS = 'providers/clearOptions'; // // Action Creators @@ -38,35 +42,55 @@ export const clearOptions = createAction(CLEAR_OPTIONS); export const actionHandlers = handleThunks({ [FETCH_OPTIONS]: function(getState, payload, dispatch) { + const subsection = `${section}.${payload.section}`; + + if (lastActions[payload.section] && _.isEqual(payload, lastActions[payload.section].payload)) { + return; + } + + const actionId = ++lastActionId; + + lastActions[payload.section] = { + actionId, + payload + }; + dispatch(set({ - section, + section: subsection, isFetching: true })); - const oldItems = getState().providerOptions.items; - const itemSection = payload.itemSection; - const promise = requestAction(payload); promise.done((data) => { - oldItems[itemSection] = data.options || []; - - dispatch(set({ - section, - isFetching: false, - isPopulated: true, - error: null, - items: oldItems - })); + if (lastActions[payload.section]) { + if (lastActions[payload.section].actionId === actionId) { + lastActions[payload.section] = null; + } + + dispatch(set({ + section: subsection, + isFetching: false, + isPopulated: true, + error: null, + items: data.options || [] + })); + } }); promise.fail((xhr) => { - dispatch(set({ - section, - isFetching: false, - isPopulated: false, - error: xhr - })); + if (lastActions[payload.section]) { + if (lastActions[payload.section].actionId === actionId) { + lastActions[payload.section] = null; + } + + dispatch(set({ + section: subsection, + isFetching: false, + isPopulated: false, + error: xhr + })); + } }); } }); @@ -76,8 +100,12 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ - [CLEAR_OPTIONS]: function(state) { - return updateSectionState(state, section, defaultState); + [CLEAR_OPTIONS]: function(state, { payload }) { + const subsection = `${section}.${payload.section}`; + + lastActions[payload.section] = null; + + return updateSectionState(state, subsection, defaultState); } -}, defaultState, section); +}, {}, section); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 4a35d5d48..47c3f573b 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -3,6 +3,7 @@ import { handleThunks } from 'Store/thunks'; import createHandleActions from './Creators/createHandleActions'; import applications from './Settings/applications'; import general from './Settings/general'; +import indexerCategories from './Settings/indexerCategories'; import indexerFlags from './Settings/indexerFlags'; import indexerOptions from './Settings/indexerOptions'; import languages from './Settings/languages'; @@ -10,6 +11,7 @@ import notifications from './Settings/notifications'; import ui from './Settings/ui'; export * from './Settings/general'; +export * from './Settings/indexerCategories'; export * from './Settings/indexerFlags'; export * from './Settings/indexerOptions'; export * from './Settings/languages'; @@ -29,6 +31,7 @@ export const defaultState = { advancedSettings: false, general: general.defaultState, + indexerCategories: indexerCategories.defaultState, indexerFlags: indexerFlags.defaultState, indexerOptions: indexerOptions.defaultState, languages: languages.defaultState, @@ -56,6 +59,7 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); export const actionHandlers = handleThunks({ ...general.actionHandlers, + ...indexerCategories.actionHandlers, ...indexerFlags.actionHandlers, ...indexerOptions.actionHandlers, ...languages.actionHandlers, @@ -74,6 +78,7 @@ export const reducers = createHandleActions({ }, ...general.reducers, + ...indexerCategories.reducers, ...indexerFlags.reducers, ...indexerOptions.reducers, ...languages.reducers, diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index b887551b4..e68db5a2c 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -19,10 +19,10 @@ namespace NzbDrone.Core.Annotations public FieldType Type { get; set; } public bool Advanced { get; set; } public Type SelectOptions { get; set; } + public string SelectOptionsProviderAction { get; set; } public string Section { get; set; } public HiddenType Hidden { get; set; } public PrivacyLevel Privacy { get; set; } - public string RequestAction { get; set; } } [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] @@ -39,6 +39,15 @@ namespace NzbDrone.Core.Annotations public string Hint { get; set; } } + public class FieldSelectOption + { + public int Value { get; set; } + public string Name { get; set; } + public int Order { get; set; } + public string Hint { get; set; } + public int? ParentValue { get; set; } + } + public enum FieldType { Textbox, diff --git a/src/NzbDrone.Core/Indexers/Definitions/Newznab/NewznabCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Definitions/Newznab/NewznabCapabilitiesProvider.cs index a94ff72ff..5b12bc76e 100644 --- a/src/NzbDrone.Core/Indexers/Definitions/Newznab/NewznabCapabilitiesProvider.cs +++ b/src/NzbDrone.Core/Indexers/Definitions/Newznab/NewznabCapabilitiesProvider.cs @@ -48,6 +48,7 @@ namespace NzbDrone.Core.Indexers.Newznab } var request = new HttpRequest(url, HttpAccept.Rss); + request.AllowAutoRedirect = true; HttpResponse response; diff --git a/src/NzbDrone.Core/Indexers/Definitions/Newznab/NewznabRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Definitions/Newznab/NewznabRequestGenerator.cs index 1ca43027a..f0de463aa 100644 --- a/src/NzbDrone.Core/Indexers/Definitions/Newznab/NewznabRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Definitions/Newznab/NewznabRequestGenerator.cs @@ -133,7 +133,7 @@ namespace NzbDrone.Core.Indexers.Newznab if (categories != null && categories.Any()) { var categoriesQuery = string.Join(",", categories.Distinct()); - baseUrl += string.Format("&cats={0}", categoriesQuery); + baseUrl += string.Format("&cat={0}", categoriesQuery); } if (Settings.ApiKey.IsNotNullOrWhiteSpace()) @@ -151,14 +151,7 @@ namespace NzbDrone.Core.Indexers.Newznab parameters += string.Format("&offset={0}", searchCriteria.Offset); } - if (PageSize == 0) - { - yield return new IndexerRequest(string.Format("{0}{1}", baseUrl, parameters), HttpAccept.Rss); - } - else - { - yield return new IndexerRequest(string.Format("{0}&offset={1}&limit={2}{3}", baseUrl, searchCriteria.Offset, searchCriteria.Limit, parameters), HttpAccept.Rss); - } + yield return new IndexerRequest(string.Format("{0}{1}", baseUrl, parameters), HttpAccept.Rss); } private static string NewsnabifyTitle(string title) diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index b9d6ed555..318d0e589 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -358,7 +358,14 @@ namespace NzbDrone.Core.Indexers _indexerStatusService.UpdateCookies(Definition.Id, cookies, expiration); }; var generator = GetRequestGenerator(); - var releases = FetchPage(generator.GetSearchRequests(new MovieSearchCriteria()).GetAllTiers().First().First(), parser); + var firstRequest = generator.GetSearchRequests(new BasicSearchCriteria()).GetAllTiers().FirstOrDefault()?.FirstOrDefault(); + + if (firstRequest == null) + { + return new ValidationFailure(string.Empty, "No rss feed query available. This may be an issue with the indexer or your indexer category settings."); + } + + var releases = FetchPage(firstRequest, parser); if (releases.Empty()) { diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs index 7d3167987..d4e9d0661 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs @@ -28,7 +28,7 @@ namespace NzbDrone.Core.Notifications.PushBullet [FieldDefinition(0, Label = "Access Token", Privacy = PrivacyLevel.ApiKey, HelpLink = "https://www.pushbullet.com/#settings/account")] public string ApiKey { get; set; } - [FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device, RequestAction = "getDevices")] + [FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device)] public IEnumerable DeviceIds { get; set; } [FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)] diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesModule.cs b/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesModule.cs new file mode 100644 index 000000000..3420e5c17 --- /dev/null +++ b/src/Prowlarr.Api.V1/Indexers/IndexerDefaultCategoriesModule.cs @@ -0,0 +1,19 @@ +using NzbDrone.Core.Indexers; +using Prowlarr.Api.V1; + +namespace NzbDrone.Api.V1.Indexers +{ + public class IndexerDefaultCategoriesModule : ProwlarrV1Module + { + public IndexerDefaultCategoriesModule() + : base("/indexer/categories") + { + Get("/", movie => GetAll()); + } + + private IndexerCategory[] GetAll() + { + return NewznabStandardCategory.ParentCats; + } + } +} diff --git a/src/Prowlarr.Api.V1/ProviderModuleBase.cs b/src/Prowlarr.Api.V1/ProviderModuleBase.cs index 3c49ebf7f..d93045ef7 100644 --- a/src/Prowlarr.Api.V1/ProviderModuleBase.cs +++ b/src/Prowlarr.Api.V1/ProviderModuleBase.cs @@ -28,7 +28,7 @@ namespace Prowlarr.Api.V1 Get("schema", x => GetTemplates()); Post("test", x => Test(ReadResourceFromRequest(true))); Post("testall", x => TestAll()); - Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true))); + Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true, true))); GetResourceAll = GetAll; GetResourceById = GetProviderById; diff --git a/src/Prowlarr.Api.V1/Search/SearchModule.cs b/src/Prowlarr.Api.V1/Search/SearchModule.cs index 8208c21d6..766c85404 100644 --- a/src/Prowlarr.Api.V1/Search/SearchModule.cs +++ b/src/Prowlarr.Api.V1/Search/SearchModule.cs @@ -35,22 +35,22 @@ namespace Prowlarr.Api.V1.Search if (indexerIds.Count > 0) { - return GetSearchReleases(request.Query, indexerIds); + return GetSearchReleases(request.Query, indexerIds, request.Categories); } else { - return GetSearchReleases(request.Query, null); + return GetSearchReleases(request.Query, null, request.Categories); } } return new List(); } - private List GetSearchReleases(string query, List indexerIds) + private List GetSearchReleases(string query, List indexerIds, int[] categories) { try { - var decisions = _nzbSearhService.Search(new NewznabRequest { q = query, t = "search" }, indexerIds, true).Releases; + var decisions = _nzbSearhService.Search(new NewznabRequest { q = query, t = "search", cat = string.Join(",", categories) }, indexerIds, true).Releases; return MapDecisions(decisions); } diff --git a/src/Prowlarr.Http/ClientSchema/Field.cs b/src/Prowlarr.Http/ClientSchema/Field.cs index ba3d465fd..61db3cfc2 100644 --- a/src/Prowlarr.Http/ClientSchema/Field.cs +++ b/src/Prowlarr.Http/ClientSchema/Field.cs @@ -15,9 +15,10 @@ namespace Prowlarr.Http.ClientSchema public string Type { get; set; } public bool Advanced { get; set; } public List SelectOptions { get; set; } + + public string SelectOptionsProviderAction { get; set; } public string Section { get; set; } public string Hidden { get; set; } - public string RequestAction { get; set; } public Field Clone() { diff --git a/src/Prowlarr.Http/ClientSchema/SchemaBuilder.cs b/src/Prowlarr.Http/ClientSchema/SchemaBuilder.cs index 4febd4be3..0b37bbdbd 100644 --- a/src/Prowlarr.Http/ClientSchema/SchemaBuilder.cs +++ b/src/Prowlarr.Http/ClientSchema/SchemaBuilder.cs @@ -100,13 +100,19 @@ namespace Prowlarr.Http.ClientSchema Order = fieldAttribute.Order, Advanced = fieldAttribute.Advanced, Type = fieldAttribute.Type.ToString().FirstCharToLower(), - Section = fieldAttribute.Section, - RequestAction = fieldAttribute.RequestAction + Section = fieldAttribute.Section }; if (fieldAttribute.Type == FieldType.Select || fieldAttribute.Type == FieldType.TagSelect) { - field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); + if (fieldAttribute.SelectOptionsProviderAction.IsNotNullOrWhiteSpace()) + { + field.SelectOptionsProviderAction = fieldAttribute.SelectOptionsProviderAction; + } + else + { + field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); + } } if (fieldAttribute.Hidden != HiddenType.Visible) diff --git a/src/Prowlarr.Http/REST/RestModule.cs b/src/Prowlarr.Http/REST/RestModule.cs index 89722a699..82bf9c30d 100644 --- a/src/Prowlarr.Http/REST/RestModule.cs +++ b/src/Prowlarr.Http/REST/RestModule.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using FluentValidation; +using FluentValidation.Results; using Nancy; using Nancy.Responses.Negotiation; using Newtonsoft.Json; @@ -224,7 +225,7 @@ namespace Prowlarr.Http.REST return Negotiate.WithModel(model).WithStatusCode(statusCode); } - protected TResource ReadResourceFromRequest(bool skipValidate = false) + protected TResource ReadResourceFromRequest(bool skipValidate = false, bool skipSharedValidate = false) { TResource resource; @@ -242,7 +243,12 @@ namespace Prowlarr.Http.REST throw new BadRequestException("Request body can't be empty"); } - var errors = SharedValidator.Validate(resource).Errors.ToList(); + var errors = new List(); + + if (!skipSharedValidate) + { + errors.AddRange(SharedValidator.Validate(resource).Errors); + } if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase)) {