From f64f8e915f14ff563a900360afbcebb46712d204 Mon Sep 17 00:00:00 2001 From: Qstick Date: Tue, 18 May 2021 00:17:04 -0400 Subject: [PATCH] New: App Sync Profiles --- ...ppProfileFilterBuilderRowValueConnector.js | 28 +++ .../Filter/Builder/FilterBuilderRow.js | 4 + .../Form/AppProfileSelectInputConnector.js | 99 ++++++++++ .../src/Components/Form/FormInputGroup.js | 4 + frontend/src/Components/Page/PageConnector.js | 15 +- .../Helpers/Props/filterBuilderValueTypes.js | 1 + frontend/src/Helpers/Props/inputTypes.js | 2 + .../Indexer/Edit/EditIndexerModalContent.js | 12 ++ .../src/Indexer/Editor/IndexerEditorFooter.js | 19 ++ .../Index/IndexerIndexItemConnector.js | 4 + .../Index/Menus/IndexerIndexSortMenu.js | 9 + .../Index/Table/IndexerIndexHeader.css | 6 + .../Indexer/Index/Table/IndexerIndexRow.css | 6 + .../Indexer/Index/Table/IndexerIndexRow.js | 13 ++ .../Applications/ApplicationSettings.js | 2 + .../src/Settings/Profiles/App/AppProfile.css | 31 +++ .../src/Settings/Profiles/App/AppProfile.js | 155 +++++++++++++++ .../Profiles/App/AppProfileNameConnector.js | 31 +++ .../src/Settings/Profiles/App/AppProfiles.css | 21 ++ .../src/Settings/Profiles/App/AppProfiles.js | 107 ++++++++++ .../Profiles/App/AppProfilesConnector.js | 63 ++++++ .../Profiles/App/EditAppProfileModal.js | 37 ++++ .../App/EditAppProfileModalConnector.js | 43 ++++ .../App/EditAppProfileModalContent.css | 3 + .../App/EditAppProfileModalContent.js | 184 ++++++++++++++++++ .../EditAppProfileModalContentConnector.js | 82 ++++++++ .../src/Store/Actions/Settings/appProfiles.js | 97 +++++++++ .../src/Store/Actions/indexerIndexActions.js | 12 ++ frontend/src/Store/Actions/settingsActions.js | 5 + .../Selectors/createAppProfileSelector.js | 15 ++ .../createIndexerAppProfileSelector.js | 16 ++ .../Selectors/createProfileInUseSelector.js | 23 +++ frontend/src/Styles/Variables/dimensions.js | 4 +- .../Applications/Lidarr/Lidarr.cs | 6 +- .../Applications/Radarr/Radarr.cs | 6 +- .../Applications/Readarr/Readarr.cs | 6 +- .../Applications/Sonarr/Sonarr.cs | 6 +- .../Datastore/Migration/006_app_profiles.cs | 21 ++ src/NzbDrone.Core/Datastore/TableMapping.cs | 6 +- .../Indexers/IndexerDefinition.cs | 4 + src/NzbDrone.Core/Localization/Core/en.json | 9 +- src/NzbDrone.Core/Profiles/AppSyncProfile.cs | 12 ++ .../Profiles/AppSyncProfileRepository.cs | 33 ++++ .../Profiles/AppSyncProfileService.cs | 104 ++++++++++ .../Profiles/ProfileInUseException.cs | 12 ++ .../Indexers/IndexerEditorController.cs | 7 +- .../Indexers/IndexerEditorResource.cs | 1 + .../Indexers/IndexerResource.cs | 3 + .../Profiles/App/AppProfileController.cs | 57 ++++++ .../Profiles/App/AppProfileResource.cs | 57 ++++++ .../Profiles/App/AppProfileSchemaModule.cs | 25 +++ 51 files changed, 1509 insertions(+), 19 deletions(-) create mode 100644 frontend/src/Components/Filter/Builder/AppProfileFilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Form/AppProfileSelectInputConnector.js create mode 100644 frontend/src/Settings/Profiles/App/AppProfile.css create mode 100644 frontend/src/Settings/Profiles/App/AppProfile.js create mode 100644 frontend/src/Settings/Profiles/App/AppProfileNameConnector.js create mode 100644 frontend/src/Settings/Profiles/App/AppProfiles.css create mode 100644 frontend/src/Settings/Profiles/App/AppProfiles.js create mode 100644 frontend/src/Settings/Profiles/App/AppProfilesConnector.js create mode 100644 frontend/src/Settings/Profiles/App/EditAppProfileModal.js create mode 100644 frontend/src/Settings/Profiles/App/EditAppProfileModalConnector.js create mode 100644 frontend/src/Settings/Profiles/App/EditAppProfileModalContent.css create mode 100644 frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js create mode 100644 frontend/src/Settings/Profiles/App/EditAppProfileModalContentConnector.js create mode 100644 frontend/src/Store/Actions/Settings/appProfiles.js create mode 100644 frontend/src/Store/Selectors/createAppProfileSelector.js create mode 100644 frontend/src/Store/Selectors/createIndexerAppProfileSelector.js create mode 100644 frontend/src/Store/Selectors/createProfileInUseSelector.js create mode 100644 src/NzbDrone.Core/Datastore/Migration/006_app_profiles.cs create mode 100644 src/NzbDrone.Core/Profiles/AppSyncProfile.cs create mode 100644 src/NzbDrone.Core/Profiles/AppSyncProfileRepository.cs create mode 100644 src/NzbDrone.Core/Profiles/AppSyncProfileService.cs create mode 100644 src/NzbDrone.Core/Profiles/ProfileInUseException.cs create mode 100644 src/Prowlarr.Api.V1/Profiles/App/AppProfileController.cs create mode 100644 src/Prowlarr.Api.V1/Profiles/App/AppProfileResource.cs create mode 100644 src/Prowlarr.Api.V1/Profiles/App/AppProfileSchemaModule.cs diff --git a/frontend/src/Components/Filter/Builder/AppProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/AppProfileFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..4da439fa0 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/AppProfileFilterBuilderRowValueConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.appProfiles, + (appProfiles) => { + const tagList = appProfiles.items.map((appProfile) => { + const { + id, + name + } = appProfile; + + return { + id, + name + }; + }); + + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index d48c0951a..9add7fb3b 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import SelectInput from 'Components/Form/SelectInput'; import IconButton from 'Components/Link/IconButton'; import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import AppProfileFilterBuilderRowValueConnector from './AppProfileFilterBuilderRowValueConnector'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; @@ -47,6 +48,9 @@ function getRowValueConnector(selectedFilterBuilderProp) { const valueType = selectedFilterBuilderProp.valueType; switch (valueType) { + case filterBuilderValueTypes.APP_PROFILE: + return AppProfileFilterBuilderRowValueConnector; + case filterBuilderValueTypes.BOOL: return BoolFilterBuilderRowValue; diff --git a/frontend/src/Components/Form/AppProfileSelectInputConnector.js b/frontend/src/Components/Form/AppProfileSelectInputConnector.js new file mode 100644 index 000000000..44a81aaca --- /dev/null +++ b/frontend/src/Components/Form/AppProfileSelectInputConnector.js @@ -0,0 +1,99 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import sortByName from 'Utilities/Array/sortByName'; +import SelectInput from './SelectInput'; + +function createMapStateToProps() { + return createSelector( + createSortedSectionSelector('settings.appProfiles', sortByName), + (state, { includeNoChange }) => includeNoChange, + (state, { includeMixed }) => includeMixed, + (appProfiles, includeNoChange, includeMixed) => { + const values = _.map(appProfiles.items, (appProfile) => { + return { + key: appProfile.id, + value: appProfile.name + }; + }); + + if (includeNoChange) { + values.unshift({ + key: 'noChange', + value: 'No Change', + disabled: true + }); + } + + if (includeMixed) { + values.unshift({ + key: 'mixed', + value: '(Mixed)', + disabled: true + }); + } + + return { + values + }; + } + ); +} + +class AppProfileSelectInputConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + name, + value, + values + } = this.props; + + if (!value || !values.some((v) => v.key === value) ) { + const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key))); + + if (firstValue) { + this.onChange({ name, value: firstValue.key }); + } + } + } + + // + // Listeners + + onChange = ({ name, value }) => { + this.props.onChange({ name, value: parseInt(value) }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AppProfileSelectInputConnector.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + values: PropTypes.arrayOf(PropTypes.object).isRequired, + includeNoChange: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired +}; + +AppProfileSelectInputConnector.defaultProps = { + includeNoChange: false +}; + +export default connect(createMapStateToProps)(AppProfileSelectInputConnector); diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 97775c2a7..f95cd40bf 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -3,6 +3,7 @@ import React from 'react'; import Link from 'Components/Link/Link'; import { inputTypes } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; +import AppProfileSelectInputConnector from './AppProfileSelectInputConnector'; import AutoCompleteInput from './AutoCompleteInput'; import AvailabilitySelectInput from './AvailabilitySelectInput'; import CaptchaInputConnector from './CaptchaInputConnector'; @@ -29,6 +30,9 @@ import styles from './FormInputGroup.css'; function getComponent(type) { switch (type) { + case inputTypes.APP_PROFILE_SELECT: + return AppProfileSelectInputConnector; + case inputTypes.AUTO_COMPLETE: return AutoCompleteInput; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 7e6968155..b2f026801 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -7,7 +7,7 @@ import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchIndexers } from 'Store/Actions/indexerActions'; import { fetchIndexerStatus } from 'Store/Actions/indexerStatusActions'; -import { fetchGeneralSettings, fetchIndexerCategories, fetchLanguages, fetchUISettings } from 'Store/Actions/settingsActions'; +import { fetchAppProfiles, fetchGeneralSettings, fetchIndexerCategories, fetchLanguages, fetchUISettings } from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; @@ -49,6 +49,7 @@ const selectIsPopulated = createSelector( (state) => state.settings.ui.isPopulated, (state) => state.settings.general.isPopulated, (state) => state.settings.languages.isPopulated, + (state) => state.settings.appProfiles.isPopulated, (state) => state.indexers.isPopulated, (state) => state.indexerStatus.isPopulated, (state) => state.settings.indexerCategories.isPopulated, @@ -59,6 +60,7 @@ const selectIsPopulated = createSelector( uiSettingsIsPopulated, generalSettingsIsPopulated, languagesIsPopulated, + appProfilesIsPopulated, indexersIsPopulated, indexerStatusIsPopulated, indexerCategoriesIsPopulated, @@ -70,6 +72,7 @@ const selectIsPopulated = createSelector( uiSettingsIsPopulated && generalSettingsIsPopulated && languagesIsPopulated && + appProfilesIsPopulated && indexersIsPopulated && indexerStatusIsPopulated && indexerCategoriesIsPopulated && @@ -84,6 +87,7 @@ const selectErrors = createSelector( (state) => state.settings.ui.error, (state) => state.settings.general.error, (state) => state.settings.languages.error, + (state) => state.settings.appProfiles.error, (state) => state.indexers.error, (state) => state.indexerStatus.error, (state) => state.settings.indexerCategories.error, @@ -94,6 +98,7 @@ const selectErrors = createSelector( uiSettingsError, generalSettingsError, languagesError, + appProfilesError, indexersError, indexerStatusError, indexerCategoriesError, @@ -105,6 +110,7 @@ const selectErrors = createSelector( uiSettingsError || generalSettingsError || languagesError || + appProfilesError || indexersError || indexerStatusError || indexerCategoriesError || @@ -118,6 +124,7 @@ const selectErrors = createSelector( uiSettingsError, generalSettingsError, languagesError, + appProfilesError, indexersError, indexerStatusError, indexerCategoriesError, @@ -174,6 +181,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchUISettings() { dispatch(fetchUISettings()); }, + dispatchFetchAppProfiles() { + dispatch(fetchAppProfiles()); + }, dispatchFetchGeneralSettings() { dispatch(fetchGeneralSettings()); }, @@ -207,6 +217,7 @@ class PageConnector extends Component { this.props.dispatchFetchCustomFilters(); this.props.dispatchFetchTags(); this.props.dispatchFetchLanguages(); + this.props.dispatchFetchAppProfiles(); this.props.dispatchFetchIndexers(); this.props.dispatchFetchIndexerStatus(); this.props.dispatchFetchIndexerCategories(); @@ -232,6 +243,7 @@ class PageConnector extends Component { hasError, dispatchFetchTags, dispatchFetchLanguages, + dispatchFetchAppProfiles, dispatchFetchIndexers, dispatchFetchIndexerStatus, dispatchFetchIndexerCategories, @@ -272,6 +284,7 @@ PageConnector.propTypes = { dispatchFetchCustomFilters: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchLanguages: PropTypes.func.isRequired, + dispatchFetchAppProfiles: PropTypes.func.isRequired, dispatchFetchIndexers: PropTypes.func.isRequired, dispatchFetchIndexerStatus: PropTypes.func.isRequired, dispatchFetchIndexerCategories: PropTypes.func.isRequired, diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js index 06ea326a3..b31bd5043 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -4,5 +4,6 @@ export const DATE = 'date'; export const DEFAULT = 'default'; export const INDEXER = 'indexer'; export const PROTOCOL = 'protocol'; +export const APP_PROFILE = 'appProfile'; export const MOVIE_STATUS = 'movieStatus'; export const TAG = 'tag'; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index f2e69b165..4af772636 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -1,4 +1,5 @@ export const AUTO_COMPLETE = 'autoComplete'; +export const APP_PROFILE_SELECT = 'appProfileSelect'; export const AVAILABILITY_SELECT = 'availabilitySelect'; export const CAPTCHA = 'captcha'; export const CARDIGANNCAPTCHA = 'cardigannCaptcha'; @@ -22,6 +23,7 @@ export const TAG_SELECT = 'tagSelect'; export const all = [ AUTO_COMPLETE, + APP_PROFILE_SELECT, AVAILABILITY_SELECT, CAPTCHA, CARDIGANNCAPTCHA, diff --git a/frontend/src/Indexer/Edit/EditIndexerModalContent.js b/frontend/src/Indexer/Edit/EditIndexerModalContent.js index 3108041ed..20d561797 100644 --- a/frontend/src/Indexer/Edit/EditIndexerModalContent.js +++ b/frontend/src/Indexer/Edit/EditIndexerModalContent.js @@ -42,6 +42,7 @@ function EditIndexerModalContent(props) { redirect, supportsRss, supportsRedirect, + appProfileId, fields, priority } = item; @@ -105,6 +106,17 @@ function EditIndexerModalContent(props) { /> + + {translate('AppProfile')} + + + + { fields ? fields.map((field) => { diff --git a/frontend/src/Indexer/Editor/IndexerEditorFooter.js b/frontend/src/Indexer/Editor/IndexerEditorFooter.js index 5053a5340..5c59a1591 100644 --- a/frontend/src/Indexer/Editor/IndexerEditorFooter.js +++ b/frontend/src/Indexer/Editor/IndexerEditorFooter.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import AppProfileSelectInputConnector from 'Components/Form/AppProfileSelectInputConnector'; import SelectInput from 'Components/Form/SelectInput'; import SpinnerButton from 'Components/Link/SpinnerButton'; import PageContentFooter from 'Components/Page/PageContentFooter'; @@ -22,6 +23,7 @@ class IndexerEditorFooter extends Component { this.state = { enable: NO_CHANGE, + appProfileId: NO_CHANGE, savingTags: false, isDeleteMovieModalOpen: false, isTagsModalOpen: false @@ -37,6 +39,7 @@ class IndexerEditorFooter extends Component { if (prevProps.isSaving && !isSaving && !saveError) { this.setState({ enable: NO_CHANGE, + appProfileId: NO_CHANGE, savingTags: false }); } @@ -99,6 +102,7 @@ class IndexerEditorFooter extends Component { const { enable, + appProfileId, savingTags, isTagsModalOpen, isDeleteMovieModalOpen @@ -127,6 +131,21 @@ class IndexerEditorFooter extends Component { /> +
+ + + +
+
+ + {translate('AppProfile')} + + + {appProfile.name} + + ); + } + if (column.name === 'capabilities') { return ( + ); diff --git a/frontend/src/Settings/Profiles/App/AppProfile.css b/frontend/src/Settings/Profiles/App/AppProfile.css new file mode 100644 index 000000000..a6123f8e5 --- /dev/null +++ b/frontend/src/Settings/Profiles/App/AppProfile.css @@ -0,0 +1,31 @@ +.appProfile { + composes: card from '~Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Profiles/App/AppProfile.js b/frontend/src/Settings/Profiles/App/AppProfile.js new file mode 100644 index 000000000..e1146986d --- /dev/null +++ b/frontend/src/Settings/Profiles/App/AppProfile.js @@ -0,0 +1,155 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import { icons, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import EditAppProfileModalConnector from './EditAppProfileModalConnector'; +import styles from './AppProfile.css'; + +class AppProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditAppProfileModalOpen: false, + isDeleteAppProfileModalOpen: false + }; + } + + // + // Listeners + + onEditAppProfilePress = () => { + this.setState({ isEditAppProfileModalOpen: true }); + } + + onEditAppProfileModalClose = () => { + this.setState({ isEditAppProfileModalOpen: false }); + } + + onDeleteAppProfilePress = () => { + this.setState({ + isEditAppProfileModalOpen: false, + isDeleteAppProfileModalOpen: true + }); + } + + onDeleteAppProfileModalClose = () => { + this.setState({ isDeleteAppProfileModalOpen: false }); + } + + onConfirmDeleteAppProfile = () => { + this.props.onConfirmDeleteAppProfile(this.props.id); + } + + onCloneAppProfilePress = () => { + const { + id, + onCloneAppProfilePress + } = this.props; + + onCloneAppProfilePress(id); + } + + // + // Render + + render() { + const { + id, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + isDeleting + } = this.props; + + return ( + +
+
+ {name} +
+ + +
+ +
+ { + + } + + { + + } + + { + + } +
+ + + + +
+ ); + } +} + +AppProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enableRss: PropTypes.bool.isRequired, + enableAutomaticSearch: PropTypes.bool.isRequired, + enableInteractiveSearch: PropTypes.bool.isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteAppProfile: PropTypes.func.isRequired, + onCloneAppProfilePress: PropTypes.func.isRequired +}; + +export default AppProfile; diff --git a/frontend/src/Settings/Profiles/App/AppProfileNameConnector.js b/frontend/src/Settings/Profiles/App/AppProfileNameConnector.js new file mode 100644 index 000000000..d8b6d341a --- /dev/null +++ b/frontend/src/Settings/Profiles/App/AppProfileNameConnector.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAppProfileSelector from 'Store/Selectors/createAppProfileSelector'; + +function createMapStateToProps() { + return createSelector( + createAppProfileSelector(), + (appProfile) => { + return { + name: appProfile.name + }; + } + ); +} + +function AppProfileNameConnector({ name, ...otherProps }) { + return ( + + {name} + + ); +} + +AppProfileNameConnector.propTypes = { + appProfileId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(AppProfileNameConnector); diff --git a/frontend/src/Settings/Profiles/App/AppProfiles.css b/frontend/src/Settings/Profiles/App/AppProfiles.css new file mode 100644 index 000000000..7d7e52246 --- /dev/null +++ b/frontend/src/Settings/Profiles/App/AppProfiles.css @@ -0,0 +1,21 @@ +.appProfiles { + display: flex; + flex-wrap: wrap; +} + +.addAppProfile { + composes: appProfile from '~./AppProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/App/AppProfiles.js b/frontend/src/Settings/Profiles/App/AppProfiles.js new file mode 100644 index 000000000..c4d46a96e --- /dev/null +++ b/frontend/src/Settings/Profiles/App/AppProfiles.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import AppProfile from './AppProfile'; +import EditAppProfileModalConnector from './EditAppProfileModalConnector'; +import styles from './AppProfiles.css'; + +class AppProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAppProfileModalOpen: false + }; + } + + // + // Listeners + + onCloneAppProfilePress = (id) => { + this.props.onCloneAppProfilePress(id); + this.setState({ isAppProfileModalOpen: true }); + } + + onEditAppProfilePress = () => { + this.setState({ isAppProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAppProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + isDeleting, + onConfirmDeleteAppProfile, + onCloneAppProfilePress, + ...otherProps + } = this.props; + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + +
+
+ ); + } +} + +AppProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteAppProfile: PropTypes.func.isRequired, + onCloneAppProfilePress: PropTypes.func.isRequired +}; + +export default AppProfiles; diff --git a/frontend/src/Settings/Profiles/App/AppProfilesConnector.js b/frontend/src/Settings/Profiles/App/AppProfilesConnector.js new file mode 100644 index 000000000..3948fcdb7 --- /dev/null +++ b/frontend/src/Settings/Profiles/App/AppProfilesConnector.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cloneAppProfile, deleteAppProfile, fetchAppProfiles } from 'Store/Actions/settingsActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import sortByName from 'Utilities/Array/sortByName'; +import AppProfiles from './AppProfiles'; + +function createMapStateToProps() { + return createSelector( + createSortedSectionSelector('settings.appProfiles', sortByName), + (appProfiles) => appProfiles + ); +} + +const mapDispatchToProps = { + dispatchFetchAppProfiles: fetchAppProfiles, + dispatchDeleteAppProfile: deleteAppProfile, + dispatchCloneAppProfile: cloneAppProfile +}; + +class AppProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchAppProfiles(); + } + + // + // Listeners + + onConfirmDeleteAppProfile = (id) => { + this.props.dispatchDeleteAppProfile({ id }); + } + + onCloneAppProfilePress = (id) => { + this.props.dispatchCloneAppProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AppProfilesConnector.propTypes = { + dispatchFetchAppProfiles: PropTypes.func.isRequired, + dispatchDeleteAppProfile: PropTypes.func.isRequired, + dispatchCloneAppProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AppProfilesConnector); diff --git a/frontend/src/Settings/Profiles/App/EditAppProfileModal.js b/frontend/src/Settings/Profiles/App/EditAppProfileModal.js new file mode 100644 index 000000000..21eed9808 --- /dev/null +++ b/frontend/src/Settings/Profiles/App/EditAppProfileModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditAppProfileModalContentConnector from './EditAppProfileModalContentConnector'; + +class EditAppProfileModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +EditAppProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditAppProfileModal; diff --git a/frontend/src/Settings/Profiles/App/EditAppProfileModalConnector.js b/frontend/src/Settings/Profiles/App/EditAppProfileModalConnector.js new file mode 100644 index 000000000..c6e1f849e --- /dev/null +++ b/frontend/src/Settings/Profiles/App/EditAppProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditAppProfileModal from './EditAppProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditAppProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.appProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditAppProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditAppProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.css b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.css new file mode 100644 index 000000000..74dd1c8b7 --- /dev/null +++ b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.css @@ -0,0 +1,3 @@ +.deleteButtonContainer { + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js new file mode 100644 index 000000000..c3722c7e0 --- /dev/null +++ b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js @@ -0,0 +1,184 @@ +import PropTypes from 'prop-types'; +import React, { Component } 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 LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './EditAppProfileModalContent.css'; + +class EditAppProfileModalContent extends Component { + + // + // Render + + render() { + const { + isFetching, + error, + isSaving, + saveError, + item, + isInUse, + onInputChange, + onSavePress, + onModalClose, + onDeleteAppProfilePress, + ...otherProps + } = this.props; + + const { + id, + name, + enableRss, + enableInteractiveSearch, + enableAutomaticSearch + } = item; + + return ( + + + + {id ? translate('EditAppProfile') : translate('AddAppProfile')} + + + +
+ { + isFetching && + + } + + { + !isFetching && !!error && +
+ {translate('UnableToAddANewAppProfilePleaseTryAgain')} +
+ } + + { + !isFetching && !error && +
+ + + {translate('Name')} + + + + + + + + {translate('EnableRss')} + + + + + + + + {translate('EnableInteractiveSearch')} + + + + + + + + {translate('EnableAutomaticSearch')} + + + + +
+ } +
+
+ + { + id ? +
+ +
: + null + } + + + + + {translate('Save')} + +
+
+ ); + } +} + +EditAppProfileModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + isInUse: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteAppProfilePress: PropTypes.func +}; + +export default EditAppProfileModalContent; diff --git a/frontend/src/Settings/Profiles/App/EditAppProfileModalContentConnector.js b/frontend/src/Settings/Profiles/App/EditAppProfileModalContentConnector.js new file mode 100644 index 000000000..8e92f8204 --- /dev/null +++ b/frontend/src/Settings/Profiles/App/EditAppProfileModalContentConnector.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchAppProfileSchema, saveAppProfile, setAppProfileValue } from 'Store/Actions/settingsActions'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import EditAppProfileModalContent from './EditAppProfileModalContent'; + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector('appProfiles'), + createProfileInUseSelector('appProfileId'), + (appProfile, isInUse) => { + return { + ...appProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchAppProfileSchema, + setAppProfileValue, + saveAppProfile +}; + +class EditAppProfileModalContentConnector extends Component { + + componentDidMount() { + if (!this.props.id && !this.props.isPopulated) { + this.props.fetchAppProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setAppProfileValue({ name, value }); + } + + onSavePress = () => { + this.props.saveAppProfile({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditAppProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setAppProfileValue: PropTypes.func.isRequired, + fetchAppProfileSchema: PropTypes.func.isRequired, + saveAppProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditAppProfileModalContentConnector); diff --git a/frontend/src/Store/Actions/Settings/appProfiles.js b/frontend/src/Store/Actions/Settings/appProfiles.js new file mode 100644 index 000000000..70f8a8961 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/appProfiles.js @@ -0,0 +1,97 @@ +import { createAction } from 'redux-actions'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +// +// Variables + +const section = 'settings.appProfiles'; + +// +// Actions Types + +export const FETCH_APP_PROFILES = 'settings/appProfiles/fetchAppProfiles'; +export const FETCH_APP_PROFILE_SCHEMA = 'settings/appProfiles/fetchAppProfileSchema'; +export const SAVE_APP_PROFILE = 'settings/appProfiles/saveAppProfile'; +export const DELETE_APP_PROFILE = 'settings/appProfiles/deleteAppProfile'; +export const SET_APP_PROFILE_VALUE = 'settings/appProfiles/setAppProfileValue'; +export const CLONE_APP_PROFILE = 'settings/appProfiles/cloneAppProfile'; + +// +// Action Creators + +export const fetchAppProfiles = createThunk(FETCH_APP_PROFILES); +export const fetchAppProfileSchema = createThunk(FETCH_APP_PROFILE_SCHEMA); +export const saveAppProfile = createThunk(SAVE_APP_PROFILE); +export const deleteAppProfile = createThunk(DELETE_APP_PROFILE); + +export const setAppProfileValue = createAction(SET_APP_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneAppProfile = createAction(CLONE_APP_PROFILE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_APP_PROFILES]: createFetchHandler(section, '/appprofile'), + [FETCH_APP_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/appprofile/schema'), + [SAVE_APP_PROFILE]: createSaveProviderHandler(section, '/appprofile'), + [DELETE_APP_PROFILE]: createRemoveItemHandler(section, '/appprofile') + }, + + // + // Reducers + + reducers: { + [SET_APP_PROFILE_VALUE]: createSetSettingValueReducer(section), + + [CLONE_APP_PROFILE]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/indexerIndexActions.js b/frontend/src/Store/Actions/indexerIndexActions.js index dd2b48317..a9c86ff7a 100644 --- a/frontend/src/Store/Actions/indexerIndexActions.js +++ b/frontend/src/Store/Actions/indexerIndexActions.js @@ -74,6 +74,12 @@ export const defaultState = { isSortable: true, isVisible: true }, + { + name: 'appProfileId', + label: translate('AppProfile'), + isSortable: true, + isVisible: true + }, { name: 'added', label: translate('Added'), @@ -138,6 +144,12 @@ export const defaultState = { type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.PROTOCOL }, + { + name: 'appProfileId', + label: translate('AppProfile'), + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.APP_PROFILE + }, { name: 'tags', label: translate('Tags'), diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 1f033ff60..09540a2b6 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -2,6 +2,7 @@ import { createAction } from 'redux-actions'; import { handleThunks } from 'Store/thunks'; import createHandleActions from './Creators/createHandleActions'; import applications from './Settings/applications'; +import appProfiles from './Settings/appProfiles'; import development from './Settings/development'; import downloadClients from './Settings/downloadClients'; import general from './Settings/general'; @@ -16,6 +17,7 @@ export * from './Settings/indexerCategories'; export * from './Settings/languages'; export * from './Settings/notifications'; export * from './Settings/applications'; +export * from './Settings/appProfiles'; export * from './Settings/development'; export * from './Settings/ui'; @@ -36,6 +38,7 @@ export const defaultState = { languages: languages.defaultState, notifications: notifications.defaultState, applications: applications.defaultState, + appProfiles: appProfiles.defaultState, development: development.defaultState, ui: ui.defaultState }; @@ -64,6 +67,7 @@ export const actionHandlers = handleThunks({ ...languages.actionHandlers, ...notifications.actionHandlers, ...applications.actionHandlers, + ...appProfiles.actionHandlers, ...development.actionHandlers, ...ui.actionHandlers }); @@ -83,6 +87,7 @@ export const reducers = createHandleActions({ ...languages.reducers, ...notifications.reducers, ...applications.reducers, + ...appProfiles.reducers, ...development.reducers, ...ui.reducers diff --git a/frontend/src/Store/Selectors/createAppProfileSelector.js b/frontend/src/Store/Selectors/createAppProfileSelector.js new file mode 100644 index 000000000..42452ccfd --- /dev/null +++ b/frontend/src/Store/Selectors/createAppProfileSelector.js @@ -0,0 +1,15 @@ +import { createSelector } from 'reselect'; + +function createAppProfileSelector() { + return createSelector( + (state, { appProfileId }) => appProfileId, + (state) => state.settings.appProfiles.items, + (appProfileId, appProfiles) => { + return appProfiles.find((profile) => { + return profile.id === appProfileId; + }); + } + ); +} + +export default createAppProfileSelector; diff --git a/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js b/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js new file mode 100644 index 000000000..ef89945d5 --- /dev/null +++ b/frontend/src/Store/Selectors/createIndexerAppProfileSelector.js @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; +import createIndexerSelector from './createIndexerSelector'; + +function createIndexerAppProfileSelector() { + return createSelector( + (state) => state.settings.appProfiles.items, + createIndexerSelector(), + (appProfiles, indexer = {}) => { + return appProfiles.find((profile) => { + return profile.id === indexer.appProfileId; + }); + } + ); +} + +export default createIndexerAppProfileSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js new file mode 100644 index 000000000..d9445e361 --- /dev/null +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js @@ -0,0 +1,23 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllIndexersSelector from './createAllIndexersSelector'; + +function createProfileInUseSelector(profileProp) { + return createSelector( + (state, { id }) => id, + createAllIndexersSelector(), + (id, indexers) => { + if (!id) { + return false; + } + + if (_.some(indexers, { [profileProp]: id })) { + return true; + } + + return false; + } + ); +} + +export default createProfileInUseSelector; diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js index a277f51dc..a183fc46b 100644 --- a/frontend/src/Styles/Variables/dimensions.js +++ b/frontend/src/Styles/Variables/dimensions.js @@ -32,8 +32,8 @@ module.exports = { // Drag dragHandleWidth: '40px', - qualityProfileItemHeight: '30px', - qualityProfileItemDragSourcePadding: '4px', + appProfileItemHeight: '30px', + appProfileItemDragSourcePadding: '4px', // Progress Bar progressBarSmallHeight: '5px', diff --git a/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs b/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs index 4df3e8ae5..f8f97c484 100644 --- a/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs +++ b/src/NzbDrone.Core/Applications/Lidarr/Lidarr.cs @@ -137,9 +137,9 @@ namespace NzbDrone.Core.Applications.Lidarr { Id = id, Name = $"{indexer.Name} (Prowlarr)", - EnableRss = indexer.Enable, - EnableAutomaticSearch = indexer.Enable, - EnableInteractiveSearch = indexer.Enable, + EnableRss = indexer.AppProfile.Value.EnableRss, + EnableAutomaticSearch = indexer.AppProfile.Value.EnableAutomaticSearch, + EnableInteractiveSearch = indexer.AppProfile.Value.EnableInteractiveSearch, Priority = indexer.Priority, Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", ConfigContract = schema.ConfigContract, diff --git a/src/NzbDrone.Core/Applications/Radarr/Radarr.cs b/src/NzbDrone.Core/Applications/Radarr/Radarr.cs index ad0773091..4afaef197 100644 --- a/src/NzbDrone.Core/Applications/Radarr/Radarr.cs +++ b/src/NzbDrone.Core/Applications/Radarr/Radarr.cs @@ -137,9 +137,9 @@ namespace NzbDrone.Core.Applications.Radarr { Id = id, Name = $"{indexer.Name} (Prowlarr)", - EnableRss = indexer.Enable, - EnableAutomaticSearch = indexer.Enable, - EnableInteractiveSearch = indexer.Enable, + EnableRss = indexer.AppProfile.Value.EnableRss, + EnableAutomaticSearch = indexer.AppProfile.Value.EnableAutomaticSearch, + EnableInteractiveSearch = indexer.AppProfile.Value.EnableInteractiveSearch, Priority = indexer.Priority, Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", ConfigContract = schema.ConfigContract, diff --git a/src/NzbDrone.Core/Applications/Readarr/Readarr.cs b/src/NzbDrone.Core/Applications/Readarr/Readarr.cs index e4b905878..d283a0053 100644 --- a/src/NzbDrone.Core/Applications/Readarr/Readarr.cs +++ b/src/NzbDrone.Core/Applications/Readarr/Readarr.cs @@ -137,9 +137,9 @@ namespace NzbDrone.Core.Applications.Readarr { Id = id, Name = $"{indexer.Name} (Prowlarr)", - EnableRss = indexer.Enable, - EnableAutomaticSearch = indexer.Enable, - EnableInteractiveSearch = indexer.Enable, + EnableRss = indexer.AppProfile.Value.EnableRss, + EnableAutomaticSearch = indexer.AppProfile.Value.EnableAutomaticSearch, + EnableInteractiveSearch = indexer.AppProfile.Value.EnableInteractiveSearch, Priority = indexer.Priority, Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", ConfigContract = schema.ConfigContract, diff --git a/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs b/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs index 6e0875db2..9369b9577 100644 --- a/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs +++ b/src/NzbDrone.Core/Applications/Sonarr/Sonarr.cs @@ -137,9 +137,9 @@ namespace NzbDrone.Core.Applications.Sonarr { Id = id, Name = $"{indexer.Name} (Prowlarr)", - EnableRss = indexer.Enable, - EnableAutomaticSearch = indexer.Enable, - EnableInteractiveSearch = indexer.Enable, + EnableRss = indexer.AppProfile.Value.EnableRss, + EnableAutomaticSearch = indexer.AppProfile.Value.EnableAutomaticSearch, + EnableInteractiveSearch = indexer.AppProfile.Value.EnableInteractiveSearch, Priority = indexer.Priority, Implementation = indexer.Protocol == DownloadProtocol.Usenet ? "Newznab" : "Torznab", ConfigContract = schema.ConfigContract, diff --git a/src/NzbDrone.Core/Datastore/Migration/006_app_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/006_app_profiles.cs new file mode 100644 index 000000000..03993ae18 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/006_app_profiles.cs @@ -0,0 +1,21 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(6)] + public class app_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("AppSyncProfiles") + .WithColumn("Name").AsString().Unique() + .WithColumn("EnableRss").AsBoolean().NotNullable() + .WithColumn("EnableInteractiveSearch").AsBoolean().NotNullable() + .WithColumn("EnableAutomaticSearch").AsBoolean().NotNullable(); + + Alter.Table("Indexers") + .AddColumn("AppProfileId").AsInt32().NotNullable().WithDefaultValue(1); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index f72cef336..df79f29a1 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -16,6 +16,7 @@ using NzbDrone.Core.Languages; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Notifications; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles; using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Update.History; @@ -52,7 +53,8 @@ namespace NzbDrone.Core.Datastore .Ignore(i => i.SupportsSearch) .Ignore(i => i.SupportsRedirect) .Ignore(i => i.Capabilities) - .Ignore(d => d.Tags); + .Ignore(d => d.Tags) + .HasOne(a => a.AppProfile, a => a.AppProfileId); Mapper.Entity("DownloadClients").RegisterModel() .Ignore(x => x.ImplementationName) @@ -86,6 +88,8 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("CustomFilters").RegisterModel(); Mapper.Entity("UpdateHistory").RegisterModel(); + + Mapper.Entity("AppSyncProfiles").RegisterModel(); } private static void RegisterMappers() diff --git a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs index 3c9982a1e..fc8dd0c6f 100644 --- a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs +++ b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Text; +using NzbDrone.Core.Datastore; using NzbDrone.Core.Indexers.Cardigann; +using NzbDrone.Core.Profiles; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers @@ -21,6 +23,8 @@ namespace NzbDrone.Core.Indexers public int Priority { get; set; } = 25; public bool Redirect { get; set; } public DateTime Added { get; set; } + public int AppProfileId { get; set; } + public LazyLoaded AppProfile { get; set; } public IndexerStatus Status { get; set; } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 0519f26b1..a738273ad 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -2,6 +2,8 @@ "About": "About", "AcceptConfirmationModal": "Accept Confirmation Modal", "Actions": "Actions", + "AppProfile": "App Profile", + "AddAppProfile": "Add App Sync Profile", "Added": "Added", "AddedToDownloadClient": "Release added to client", "AddIndexer": "Add Indexer", @@ -24,6 +26,7 @@ "ApplyTagsHelpTexts2": "Add: Add the tags the existing list of tags", "ApplyTagsHelpTexts3": "Remove: Remove the entered tags", "ApplyTagsHelpTexts4": "Replace: Replace the tags with the entered tags (enter no tags to clear all tags)", + "AppProfiles": "App Profiles", "AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?", "Authentication": "Authentication", "AuthenticationMethodHelpText": "Require Username and Password to access Prowlarr", @@ -82,7 +85,6 @@ "DevelopmentSettings": "Development Settings", "Disabled": "Disabled", "Docker": "Docker", - "IndexerObsoleteCheckMessage": "Indexers are obsolete or have been updated: {0}. Please remove and (or) re-add to Prowlarr", "DownloadClient": "Download Client", "DownloadClientCheckNoneAvailableMessage": "No download client is available", "DownloadClientCheckUnableToCommunicateMessage": "Unable to communicate with {0}.", @@ -93,6 +95,7 @@ "DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}", "DownloadClientUnavailable": "Download client is unavailable", "Downloading": "Downloading", + "EditAppProfile": "Edit App Profile", "EditIndexer": "Edit Indexer", "Enable": "Enable", "EnableAutoHelpText": "If enabled, Movies will be automatically added to Prowlarr from this list", @@ -110,7 +113,8 @@ "EnableInteractiveSearchHelpText": "Will be used when interactive search is used", "EnableInteractiveSearchHelpTextWarning": "Search is not supported with this indexer", "EnableMediaInfoHelpText": "Extract video information such as resolution, runtime and codec information from files. This requires Prowlarr to read parts of the file which may cause high disk or network activity during scans.", - "EnableRSS": "Enable RSS", + "EnableRss": "Enable RSS", + "EnableRssHelpText": "Enable Rss feed for Indexer", "EnableSSL": "Enable SSL", "EnableSslHelpText": " Requires restart running as administrator to take effect", "Error": "Error", @@ -149,6 +153,7 @@ "IndexerHealthCheckNoIndexers": "No indexers enabled, Prowlarr will not return search results", "IndexerLongTermStatusCheckAllClientMessage": "All indexers are unavailable due to failures for more than 6 hours", "IndexerLongTermStatusCheckSingleClientMessage": "Indexers unavailable due to failures for more than 6 hours: {0}", + "IndexerObsoleteCheckMessage": "Indexers are obsolete or have been updated: {0}. Please remove and (or) re-add to Prowlarr", "IndexerPriority": "Indexer Priority", "IndexerPriorityHelpText": "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25.", "IndexerQuery": "Indexer Query", diff --git a/src/NzbDrone.Core/Profiles/AppSyncProfile.cs b/src/NzbDrone.Core/Profiles/AppSyncProfile.cs new file mode 100644 index 000000000..e7a7e896e --- /dev/null +++ b/src/NzbDrone.Core/Profiles/AppSyncProfile.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Profiles +{ + public class AppSyncProfile : ModelBase + { + public string Name { get; set; } + public bool EnableRss { get; set; } + public bool EnableAutomaticSearch { get; set; } + public bool EnableInteractiveSearch { get; set; } + } +} diff --git a/src/NzbDrone.Core/Profiles/AppSyncProfileRepository.cs b/src/NzbDrone.Core/Profiles/AppSyncProfileRepository.cs new file mode 100644 index 000000000..9e2f87f09 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/AppSyncProfileRepository.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles +{ + public interface IAppProfileRepository : IBasicRepository + { + bool Exists(int id); + } + + public class AppSyncProfileRepository : BasicRepository, IAppProfileRepository + { + public AppSyncProfileRepository(IMainDatabase database, + IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + protected override List Query(SqlBuilder builder) + { + var profiles = base.Query(builder); + + return profiles; + } + + public bool Exists(int id) + { + return Query(x => x.Id == id).Count == 1; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/AppSyncProfileService.cs b/src/NzbDrone.Core/Profiles/AppSyncProfileService.cs new file mode 100644 index 000000000..7be17ab1b --- /dev/null +++ b/src/NzbDrone.Core/Profiles/AppSyncProfileService.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles +{ + public interface IProfileService + { + AppSyncProfile Add(AppSyncProfile profile); + void Update(AppSyncProfile profile); + void Delete(int id); + List All(); + AppSyncProfile Get(int id); + bool Exists(int id); + AppSyncProfile GetDefaultProfile(string name); + } + + public class AppSyncProfileService : IProfileService, + IHandle + { + private readonly IAppProfileRepository _profileRepository; + private readonly IIndexerFactory _indexerFactory; + private readonly Logger _logger; + + public AppSyncProfileService(IAppProfileRepository profileRepository, + IIndexerFactory movieService, + Logger logger) + { + _profileRepository = profileRepository; + _indexerFactory = movieService; + _logger = logger; + } + + public AppSyncProfile Add(AppSyncProfile profile) + { + return _profileRepository.Insert(profile); + } + + public void Update(AppSyncProfile profile) + { + _profileRepository.Update(profile); + } + + public void Delete(int id) + { + if (_indexerFactory.All().Any(c => c.AppProfileId == id)) + { + throw new ProfileInUseException(id); + } + + _profileRepository.Delete(id); + } + + public List All() + { + return _profileRepository.All().ToList(); + } + + public AppSyncProfile Get(int id) + { + return _profileRepository.Get(id); + } + + public bool Exists(int id) + { + return _profileRepository.Exists(id); + } + + public void Handle(ApplicationStartedEvent message) + { + if (All().Any()) + { + return; + } + + _logger.Info("Setting up default app profile"); + + AddDefaultProfile("Standard"); + } + + public AppSyncProfile GetDefaultProfile(string name) + { + var qualityProfile = new AppSyncProfile + { + Name = name, + EnableAutomaticSearch = true, + EnableInteractiveSearch = true, + EnableRss = true + }; + + return qualityProfile; + } + + private AppSyncProfile AddDefaultProfile(string name) + { + var profile = GetDefaultProfile(name); + + return Add(profile); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/ProfileInUseException.cs b/src/NzbDrone.Core/Profiles/ProfileInUseException.cs new file mode 100644 index 000000000..6d2fef2c9 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/ProfileInUseException.cs @@ -0,0 +1,12 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Profiles +{ + public class ProfileInUseException : NzbDroneException + { + public ProfileInUseException(int profileId) + : base("Profile [{0}] is in use.", profileId) + { + } + } +} diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs b/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs index d9a5e34de..ffb0459f5 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs @@ -25,7 +25,7 @@ namespace Prowlarr.Api.V1.Indexers [HttpPut] public IActionResult SaveAll(IndexerEditorResource resource) { - var indexersToUpdate = _indexerService.All().Where(x => resource.IndexerIds.Contains(x.Id)); + var indexersToUpdate = _indexerService.AllProviders(false).Select(x => (IndexerDefinition)x.Definition).Where(d => resource.IndexerIds.Contains(d.Id)); foreach (var indexer in indexersToUpdate) { @@ -34,6 +34,11 @@ namespace Prowlarr.Api.V1.Indexers indexer.Enable = bool.Parse(resource.Enable); } + if (resource.AppProfileId.HasValue) + { + indexer.AppProfileId = resource.AppProfileId.Value; + } + if (resource.Tags != null) { var newTags = resource.Tags; diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerEditorResource.cs b/src/Prowlarr.Api.V1/Indexers/IndexerEditorResource.cs index b11f448bd..86120700e 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerEditorResource.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerEditorResource.cs @@ -6,6 +6,7 @@ namespace Prowlarr.Api.V1.Indexers { public List IndexerIds { get; set; } public string Enable { get; set; } + public int? AppProfileId { get; set; } public List Tags { get; set; } public ApplyTags ApplyTags { get; set; } } diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerResource.cs b/src/Prowlarr.Api.V1/Indexers/IndexerResource.cs index 1592df81d..fd594492e 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerResource.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerResource.cs @@ -22,6 +22,7 @@ namespace Prowlarr.Api.V1.Indexers public bool SupportsRss { get; set; } public bool SupportsSearch { get; set; } public bool SupportsRedirect { get; set; } + public int AppProfileId { get; set; } public DownloadProtocol Protocol { get; set; } public IndexerPrivacy Privacy { get; set; } public IndexerCapabilityResource Capabilities { get; set; } @@ -65,6 +66,7 @@ namespace Prowlarr.Api.V1.Indexers } } + resource.AppProfileId = definition.AppProfileId; resource.BaseUrl = definition.BaseUrl; resource.Description = definition.Description; resource.Language = definition.Language; @@ -117,6 +119,7 @@ namespace Prowlarr.Api.V1.Indexers } } + definition.AppProfileId = resource.AppProfileId; definition.Enable = resource.Enable; definition.Redirect = resource.Redirect; definition.BaseUrl = resource.BaseUrl; diff --git a/src/Prowlarr.Api.V1/Profiles/App/AppProfileController.cs b/src/Prowlarr.Api.V1/Profiles/App/AppProfileController.cs new file mode 100644 index 000000000..b3bc82a09 --- /dev/null +++ b/src/Prowlarr.Api.V1/Profiles/App/AppProfileController.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Profiles; +using NzbDrone.Http.REST.Attributes; +using Prowlarr.Http; +using Prowlarr.Http.REST; + +namespace Prowlarr.Api.V1.Profiles.App +{ + [V1ApiController] + public class AppProfileController : RestController + { + private readonly IProfileService _profileService; + + public AppProfileController(IProfileService profileService) + { + _profileService = profileService; + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + } + + [RestPostById] + public ActionResult Create(AppProfileResource resource) + { + var model = resource.ToModel(); + model = _profileService.Add(model); + return Created(model.Id); + } + + [RestDeleteById] + public void DeleteProfile(int id) + { + _profileService.Delete(id); + } + + [RestPutById] + public ActionResult Update(AppProfileResource resource) + { + var model = resource.ToModel(); + + _profileService.Update(model); + + return Accepted(model.Id); + } + + public override AppProfileResource GetResourceById(int id) + { + return _profileService.Get(id).ToResource(); + } + + [HttpGet] + public List GetAll() + { + return _profileService.All().ToResource(); + } + } +} diff --git a/src/Prowlarr.Api.V1/Profiles/App/AppProfileResource.cs b/src/Prowlarr.Api.V1/Profiles/App/AppProfileResource.cs new file mode 100644 index 000000000..ebc968b48 --- /dev/null +++ b/src/Prowlarr.Api.V1/Profiles/App/AppProfileResource.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Profiles; +using Prowlarr.Http.REST; + +namespace Prowlarr.Api.V1.Profiles.App +{ + public class AppProfileResource : RestResource + { + public string Name { get; set; } + public bool EnableRss { get; set; } + public bool EnableInteractiveSearch { get; set; } + public bool EnableAutomaticSearch { get; set; } + } + + public static class ProfileResourceMapper + { + public static AppProfileResource ToResource(this AppSyncProfile model) + { + if (model == null) + { + return null; + } + + return new AppProfileResource + { + Id = model.Id, + Name = model.Name, + EnableRss = model.EnableRss, + EnableInteractiveSearch = model.EnableInteractiveSearch, + EnableAutomaticSearch = model.EnableAutomaticSearch + }; + } + + public static AppSyncProfile ToModel(this AppProfileResource resource) + { + if (resource == null) + { + return null; + } + + return new AppSyncProfile + { + Id = resource.Id, + Name = resource.Name, + EnableRss = resource.EnableRss, + EnableInteractiveSearch = resource.EnableInteractiveSearch, + EnableAutomaticSearch = resource.EnableAutomaticSearch + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Prowlarr.Api.V1/Profiles/App/AppProfileSchemaModule.cs b/src/Prowlarr.Api.V1/Profiles/App/AppProfileSchemaModule.cs new file mode 100644 index 000000000..9eafed370 --- /dev/null +++ b/src/Prowlarr.Api.V1/Profiles/App/AppProfileSchemaModule.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Profiles; +using Prowlarr.Http; + +namespace Prowlarr.Api.V1.Profiles.App +{ + [V1ApiController("appprofile/schema")] + public class QualityProfileSchemaController : Controller + { + private readonly IProfileService _profileService; + + public QualityProfileSchemaController(IProfileService profileService) + { + _profileService = profileService; + } + + [HttpGet] + public AppProfileResource GetSchema() + { + AppSyncProfile qualityProfile = _profileService.GetDefaultProfile(string.Empty); + + return qualityProfile.ToResource(); + } + } +}