diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index c51b37f08..a668e60f2 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -13,6 +13,7 @@ import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInputConnector from './EnhancedSelectInputConnector'; import FormInputHelpText from './FormInputHelpText'; import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector'; +import IndexerSelectInputConnector from './IndexerSelectInputConnector'; import KeyValueListInput from './KeyValueListInput'; import LanguageSelectInputConnector from './LanguageSelectInputConnector'; import MovieMonitoredSelectInput from './MovieMonitoredSelectInput'; @@ -65,6 +66,9 @@ function getComponent(type) { case inputTypes.QUALITY_PROFILE_SELECT: return QualityProfileSelectInputConnector; + case inputTypes.INDEXER_SELECT: + return IndexerSelectInputConnector; + case inputTypes.MOVIE_MONITORED_SELECT: return MovieMonitoredSelectInput; diff --git a/frontend/src/Components/Form/IndexerSelectInputConnector.js b/frontend/src/Components/Form/IndexerSelectInputConnector.js new file mode 100644 index 000000000..375cc8858 --- /dev/null +++ b/frontend/src/Components/Form/IndexerSelectInputConnector.js @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers } from 'Store/Actions/settingsActions'; +import sortByName from 'Utilities/Array/sortByName'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (state, { includeAny }) => includeAny, + (indexers, includeAny) => { + const { + isFetching, + isPopulated, + error, + items + } = indexers; + + const values = items.sort(sortByName).map((indexer) => ({ + key: indexer.id, + value: indexer.name + })); + + if (includeAny) { + values.unshift({ + key: 0, + value: '(Any)' + }); + } + + return { + isFetching, + isPopulated, + error, + values + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexers: fetchIndexers +}; + +class IndexerSelectInputConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.dispatchFetchIndexers(); + } + } + + // + // Listeners + + onChange = ({ name, value }) => { + this.props.onChange({ name, value: parseInt(value) }); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +IndexerSelectInputConnector.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + includeAny: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + dispatchFetchIndexers: PropTypes.func.isRequired +}; + +IndexerSelectInputConnector.defaultProps = { + includeAny: false +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSelectInputConnector); diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js index 9f6d12689..aef065cfa 100644 --- a/frontend/src/Components/Form/TextTagInputConnector.js +++ b/frontend/src/Components/Form/TextTagInputConnector.js @@ -46,13 +46,13 @@ class TextTagInputConnector extends Component { // to oddities with restrictions (as an example). const newValue = [...valueArray]; - const newTags = split(tag.name); + const newTags = tag.name.startsWith('/') ? [tag.name] : split(tag.name); newTags.forEach((newTag) => { newValue.push(newTag.trim()); }); - onChange({ name, value: newValue.join(',') }); + onChange({ name, value: newValue }); }; onTagDelete = ({ index }) => { @@ -67,7 +67,7 @@ class TextTagInputConnector extends Component { onChange({ name, - value: newValue.join(',') + value: newValue }); }; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index ac031d4f0..f3423c507 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -10,6 +10,7 @@ export const OAUTH = 'oauth'; export const PASSWORD = 'password'; export const PATH = 'path'; export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; +export const INDEXER_SELECT = 'indexerSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const LANGUAGE_SELECT = 'languageSelect'; @@ -36,6 +37,7 @@ export const all = [ PASSWORD, PATH, QUALITY_PROFILE_SELECT, + INDEXER_SELECT, DOWNLOAD_CLIENT_SELECT, ROOT_FOLDER_SELECT, INDEXER_FLAGS_SELECT, diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js index 7a8df96ce..c3522eee3 100644 --- a/frontend/src/Settings/Indexers/IndexerSettings.js +++ b/frontend/src/Settings/Indexers/IndexerSettings.js @@ -10,7 +10,6 @@ import translate from 'Utilities/String/translate'; import IndexersConnector from './Indexers/IndexersConnector'; import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal'; import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; -import RestrictionsConnector from './Restrictions/RestrictionsConnector'; class IndexerSettings extends Component { @@ -103,8 +102,6 @@ class IndexerSettings extends Component { onChildStateChange={this.onChildStateChange} /> - - { - this.setState({ isEditRestrictionModalOpen: true }); - }; - - onEditRestrictionModalClose = () => { - this.setState({ isEditRestrictionModalOpen: false }); - }; - - onDeleteRestrictionPress = () => { - this.setState({ - isEditRestrictionModalOpen: false, - isDeleteRestrictionModalOpen: true - }); - }; - - onDeleteRestrictionModalClose= () => { - this.setState({ isDeleteRestrictionModalOpen: false }); - }; - - onConfirmDeleteRestriction = () => { - this.props.onConfirmDeleteRestriction(this.props.id); - }; - - // - // Render - - render() { - const { - id, - required, - ignored, - tags, - tagList - } = this.props; - - return ( - -
- { - split(required).map((item) => { - if (!item) { - return null; - } - - return ( - - ); - }) - } -
- -
- { - split(ignored).map((item) => { - if (!item) { - return null; - } - - return ( - - ); - }) - } -
- - - - - - -
- ); - } -} - -Restriction.propTypes = { - id: PropTypes.number.isRequired, - required: PropTypes.string.isRequired, - ignored: PropTypes.string.isRequired, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onConfirmDeleteRestriction: PropTypes.func.isRequired -}; - -Restriction.defaultProps = { - required: '', - ignored: '' -}; - -export default Restriction; diff --git a/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js b/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js deleted file mode 100644 index 7a34ffd4e..000000000 --- a/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { deleteRestriction, fetchRestrictions } from 'Store/Actions/settingsActions'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import Restrictions from './Restrictions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.restrictions, - createTagsSelector(), - (restrictions, tagList) => { - return { - ...restrictions, - tagList - }; - } - ); -} - -const mapDispatchToProps = { - fetchRestrictions, - deleteRestriction -}; - -class RestrictionsConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchRestrictions(); - } - - // - // Listeners - - onConfirmDeleteRestriction = (id) => { - this.props.deleteRestriction({ id }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -RestrictionsConnector.propTypes = { - fetchRestrictions: PropTypes.func.isRequired, - deleteRestriction: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(RestrictionsConnector); diff --git a/frontend/src/Settings/Profiles/Profiles.css b/frontend/src/Settings/Profiles/Profiles.css deleted file mode 100644 index a62558009..000000000 --- a/frontend/src/Settings/Profiles/Profiles.css +++ /dev/null @@ -1,6 +0,0 @@ -.addCustomFormatMessage { - color: var(--helpTextColor); - text-align: center; - font-weight: 300; - font-size: 20px; -} diff --git a/frontend/src/Settings/Profiles/Profiles.css.d.ts b/frontend/src/Settings/Profiles/Profiles.css.d.ts deleted file mode 100644 index 541e2cef9..000000000 --- a/frontend/src/Settings/Profiles/Profiles.css.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file is automatically generated. -// Please do not change this file! -interface CssExports { - 'addCustomFormatMessage': string; -} -export const cssExports: CssExports; -export default cssExports; diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js index 4df9289e8..5d20a25c7 100644 --- a/frontend/src/Settings/Profiles/Profiles.js +++ b/frontend/src/Settings/Profiles/Profiles.js @@ -1,14 +1,13 @@ import React, { Component } from 'react'; import { DndProvider } from 'react-dnd-multi-backend'; import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch'; -import Link from 'Components/Link/Link'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import DelayProfilesConnector from './Delay/DelayProfilesConnector'; import QualityProfilesConnector from './Quality/QualityProfilesConnector'; -import styles from './Profiles.css'; +import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector'; // Only a single DragDrop Context can exist so it's done here to allow editing // quality profiles and reordering delay profiles to work. @@ -28,11 +27,7 @@ class Profiles extends Component { -
- {translate('LookingForReleaseProfiles1')} - {translate('CustomFormats')} - {translate('LookingForReleaseProfiles2')} -
+
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js similarity index 59% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js index 2b1448d8e..a948ab123 100644 --- a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js @@ -2,16 +2,16 @@ import PropTypes from 'prop-types'; import React from 'react'; import Modal from 'Components/Modal/Modal'; import { sizes } from 'Helpers/Props'; -import EditRestrictionModalContentConnector from './EditRestrictionModalContentConnector'; +import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector'; -function EditRestrictionModal({ isOpen, onModalClose, ...otherProps }) { +function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) { return ( - @@ -19,9 +19,9 @@ function EditRestrictionModal({ isOpen, onModalClose, ...otherProps }) { ); } -EditRestrictionModal.propTypes = { +EditReleaseProfileModal.propTypes = { isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; -export default EditRestrictionModal; +export default EditReleaseProfileModal; diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js similarity index 60% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js index 8d9177581..e846ff6ff 100644 --- a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js @@ -2,19 +2,19 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { clearPendingChanges } from 'Store/Actions/baseActions'; -import EditRestrictionModal from './EditRestrictionModal'; +import EditReleaseProfileModal from './EditReleaseProfileModal'; const mapDispatchToProps = { clearPendingChanges }; -class EditRestrictionModalConnector extends Component { +class EditReleaseProfileModalConnector extends Component { // // Listeners onModalClose = () => { - this.props.clearPendingChanges({ section: 'settings.restrictions' }); + this.props.clearPendingChanges({ section: 'settings.releaseProfiles' }); this.props.onModalClose(); }; @@ -23,7 +23,7 @@ class EditRestrictionModalConnector extends Component { render() { return ( - @@ -31,9 +31,9 @@ class EditRestrictionModalConnector extends Component { } } -EditRestrictionModalConnector.propTypes = { +EditReleaseProfileModalConnector.propTypes = { onModalClose: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; -export default connect(null, mapDispatchToProps)(EditRestrictionModalConnector); +export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css new file mode 100644 index 000000000..6805d187b --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css @@ -0,0 +1,12 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.tagInternalInput { + composes: internalInput from '~Components/Form/TagInput.css'; + + flex: 0 0 100%; +} + diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css.d.ts b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css.d.ts similarity index 86% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css.d.ts rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css.d.ts index c5f0ef8a7..930ca0cb3 100644 --- a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css.d.ts +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'deleteButton': string; + 'tagInternalInput': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js similarity index 58% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js index 9070076b1..2a380207f 100644 --- a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js @@ -12,9 +12,11 @@ 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 './EditRestrictionModalContent.css'; +import styles from './EditReleaseProfileModalContent.css'; -function EditRestrictionModalContent(props) { +const tagInputDelimiters = ['Tab', 'Enter']; + +function EditReleaseProfileModalContent(props) { const { isSaving, saveError, @@ -22,27 +24,54 @@ function EditRestrictionModalContent(props) { onInputChange, onModalClose, onSavePress, - onDeleteRestrictionPress, + onDeleteReleaseProfilePress, ...otherProps } = props; const { id, + name, + enabled, required, ignored, - tags + tags, + indexerId } = item; return ( - {id ? translate('EditRestriction') : translate('AddRestriction')} + {id ? translate('Edit Release Profile') : translate('Add Release Profile')} -
+ + + + {translate('Name')} + + + + + + {translate('EnableProfile')} + + + + {translate('MustContain')} @@ -51,9 +80,10 @@ function EditRestrictionModalContent(props) { inputClassName={styles.tagInternalInput} type={inputTypes.TEXT_TAG} name="required" - helpText={translate('RequiredRestrictionHelpText')} + helpText="The release must contain at least one of these terms (case insensitive)" kind={kinds.SUCCESS} placeholder={translate('RequiredRestrictionPlaceHolder')} + delimiters={tagInputDelimiters} canEdit={true} onChange={onInputChange} /> @@ -67,21 +97,36 @@ function EditRestrictionModalContent(props) { inputClassName={styles.tagInternalInput} type={inputTypes.TEXT_TAG} name="ignored" - helpText={translate('IgnoredHelpText')} + helpText="The release will be rejected if it contains one or more of terms (case insensitive)" kind={kinds.DANGER} placeholder={translate('IgnoredPlaceHolder')} + delimiters={tagInputDelimiters} canEdit={true} onChange={onInputChange} /> + + {translate('Indexer')} + + + + {translate('Tags')} @@ -94,7 +139,7 @@ function EditRestrictionModalContent(props) { @@ -118,14 +163,14 @@ function EditRestrictionModalContent(props) { ); } -EditRestrictionModalContent.propTypes = { +EditReleaseProfileModalContent.propTypes = { isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, item: PropTypes.object.isRequired, onInputChange: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired, - onDeleteRestrictionPress: PropTypes.func + onDeleteReleaseProfilePress: PropTypes.func }; -export default EditRestrictionModalContent; +export default EditReleaseProfileModalContent; diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js similarity index 60% rename from frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js rename to frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js index 0d567720b..0371a1a7a 100644 --- a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js @@ -1,23 +1,24 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { saveRestriction, setRestrictionValue } from 'Store/Actions/settingsActions'; +import { saveReleaseProfile, setReleaseProfileValue } from 'Store/Actions/settingsActions'; import selectSettings from 'Store/Selectors/selectSettings'; -import EditRestrictionModalContent from './EditRestrictionModalContent'; - -const newRestriction = { - required: '', - ignored: '', - tags: [] +import EditReleaseProfileModalContent from './EditReleaseProfileModalContent'; + +const newReleaseProfile = { + enabled: true, + required: [], + ignored: [], + tags: [], + indexerId: 0 }; function createMapStateToProps() { return createSelector( (state, { id }) => id, - (state) => state.settings.restrictions, - (id, restrictions) => { + (state) => state.settings.releaseProfiles, + (id, releaseProfiles) => { const { isFetching, error, @@ -25,9 +26,9 @@ function createMapStateToProps() { saveError, pendingChanges, items - } = restrictions; + } = releaseProfiles; - const profile = id ? _.find(items, { id }) : newRestriction; + const profile = id ? items.find((i) => i.id === id) : newReleaseProfile; const settings = selectSettings(profile, pendingChanges, saveError); return { @@ -44,21 +45,21 @@ function createMapStateToProps() { } const mapDispatchToProps = { - setRestrictionValue, - saveRestriction + setReleaseProfileValue, + saveReleaseProfile }; -class EditRestrictionModalContentConnector extends Component { +class EditReleaseProfileModalContentConnector extends Component { // // Lifecycle componentDidMount() { if (!this.props.id) { - Object.keys(newRestriction).forEach((name) => { - this.props.setRestrictionValue({ + Object.keys(newReleaseProfile).forEach((name) => { + this.props.setReleaseProfileValue({ name, - value: newRestriction[name] + value: newReleaseProfile[name] }); }); } @@ -74,11 +75,11 @@ class EditRestrictionModalContentConnector extends Component { // Listeners onInputChange = ({ name, value }) => { - this.props.setRestrictionValue({ name, value }); + this.props.setReleaseProfileValue({ name, value }); }; onSavePress = () => { - this.props.saveRestriction({ id: this.props.id }); + this.props.saveReleaseProfile({ id: this.props.id }); }; // @@ -86,7 +87,7 @@ class EditRestrictionModalContentConnector extends Component { render() { return ( - { + this.setState({ isEditReleaseProfileModalOpen: true }); + }; + + onEditReleaseProfileModalClose = () => { + this.setState({ isEditReleaseProfileModalOpen: false }); + }; + + onDeleteReleaseProfilePress = () => { + this.setState({ + isEditReleaseProfileModalOpen: false, + isDeleteReleaseProfileModalOpen: true + }); + }; + + onDeleteReleaseProfileModalClose= () => { + this.setState({ isDeleteReleaseProfileModalOpen: false }); + }; + + onConfirmDeleteReleaseProfile = () => { + this.props.onConfirmDeleteReleaseProfile(this.props.id); + }; + + // + // Render + + render() { + const { + id, + name, + enabled, + required, + ignored, + tags, + indexerId, + tagList, + indexerList + } = this.props; + + const { + isEditReleaseProfileModalOpen, + isDeleteReleaseProfileModalOpen + } = this.state; + + const indexer = indexerList.find((i) => i.id === indexerId); + + return ( + + { + name ? +
+ {name} +
: + null + } + +
+ { + required.map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + ignored.map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ + + +
+ { + !enabled && + + } + + { + indexer && + + } +
+ + + + +
+ ); + } +} + +ReleaseProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string, + enabled: PropTypes.bool.isRequired, + required: PropTypes.arrayOf(PropTypes.string).isRequired, + ignored: PropTypes.arrayOf(PropTypes.string).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + indexerId: PropTypes.number.isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + indexerList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired +}; + +ReleaseProfile.defaultProps = { + enabled: true, + required: [], + ignored: [], + indexerId: 0 +}; + +export default ReleaseProfile; diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css similarity index 76% rename from frontend/src/Settings/Indexers/Restrictions/Restrictions.css rename to frontend/src/Settings/Profiles/Release/ReleaseProfiles.css index 16d8957c0..9e9715e77 100644 --- a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css @@ -1,10 +1,10 @@ -.restrictions { +.releaseProfiles { display: flex; flex-wrap: wrap; } -.addRestriction { - composes: restriction from '~./Restriction.css'; +.addReleaseProfile { + composes: releaseProfile from '~./ReleaseProfile.css'; background-color: var(--cardAlternateBackgroundColor); color: var(--gray); diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css.d.ts b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css.d.ts similarity index 75% rename from frontend/src/Settings/Indexers/Restrictions/Restrictions.css.d.ts rename to frontend/src/Settings/Profiles/Release/ReleaseProfiles.css.d.ts index 74a5fc15b..be1ba4596 100644 --- a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css.d.ts +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css.d.ts @@ -1,9 +1,9 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'addRestriction': string; + 'addReleaseProfile': string; 'center': string; - 'restrictions': string; + 'releaseProfiles': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.js b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js similarity index 52% rename from frontend/src/Settings/Indexers/Restrictions/Restrictions.js rename to frontend/src/Settings/Profiles/Release/ReleaseProfiles.js index 3aa2969c0..00dedf623 100644 --- a/frontend/src/Settings/Indexers/Restrictions/Restrictions.js +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js @@ -6,11 +6,11 @@ import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; import { icons } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -import EditRestrictionModalConnector from './EditRestrictionModalConnector'; -import Restriction from './Restriction'; -import styles from './Restrictions.css'; +import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; +import ReleaseProfile from './ReleaseProfile'; +import styles from './ReleaseProfiles.css'; -class Restrictions extends Component { +class ReleaseProfiles extends Component { // // Lifecycle @@ -19,19 +19,19 @@ class Restrictions extends Component { super(props, context); this.state = { - isAddRestrictionModalOpen: false + isAddReleaseProfileModalOpen: false }; } // // Listeners - onAddRestrictionPress = () => { - this.setState({ isAddRestrictionModalOpen: true }); + onAddReleaseProfilePress = () => { + this.setState({ isAddReleaseProfileModalOpen: true }); }; - onAddRestrictionModalClose = () => { - this.setState({ isAddRestrictionModalOpen: false }); + onAddReleaseProfileModalClose = () => { + this.setState({ isAddReleaseProfileModalOpen: false }); }; // @@ -41,20 +41,21 @@ class Restrictions extends Component { const { items, tagList, - onConfirmDeleteRestriction, + indexerList, + onConfirmDeleteReleaseProfile, ...otherProps } = this.props; return ( -
+
-
+
{ return ( - ); }) }
-
@@ -88,12 +90,13 @@ class Restrictions extends Component { } } -Restrictions.propTypes = { +ReleaseProfiles.propTypes = { isFetching: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onConfirmDeleteRestriction: PropTypes.func.isRequired + indexerList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired }; -export default Restrictions; +export default ReleaseProfiles; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js new file mode 100644 index 000000000..ad254b2df --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteReleaseProfile, fetchIndexers, fetchReleaseProfiles } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ReleaseProfiles from './ReleaseProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.releaseProfiles, + (state) => state.settings.indexers, + createTagsSelector(), + (releaseProfiles, indexers, tagList) => { + return { + ...releaseProfiles, + tagList, + isIndexersPopulated: indexers.isPopulated, + indexerList: indexers.items + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexers, + fetchReleaseProfiles, + deleteReleaseProfile +}; + +class ReleaseProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchReleaseProfiles(); + } + + if (!this.props.isIndexersPopulated) { + this.props.fetchIndexers(); + } + } + + // + // Listeners + + onConfirmDeleteReleaseProfile = (id) => { + this.props.deleteReleaseProfile({ id }); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +ReleaseProfilesConnector.propTypes = { + isPopulated: PropTypes.bool.isRequired, + isIndexersPopulated: PropTypes.bool.isRequired, + fetchReleaseProfiles: PropTypes.func.isRequired, + deleteReleaseProfile: PropTypes.func.isRequired, + fetchIndexers: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector); diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index 43ff2927f..ddcdf165e 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -8,7 +8,6 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; -import split from 'Utilities/String/split'; import translate from 'Utilities/String/translate'; import TagDetailsDelayProfile from './TagDetailsDelayProfile'; import styles from './TagDetailsModalContent.css'; @@ -19,9 +18,9 @@ function TagDetailsModalContent(props) { isTagUsed, movies, delayProfiles, - notifications, - restrictions, importLists, + notifications, + releaseProfiles, indexers, downloadClients, autoTags, @@ -106,10 +105,10 @@ function TagDetailsModalContent(props) { } { - restrictions.length ? -
+ releaseProfiles.length ? +
{ - restrictions.map((item) => { + releaseProfiles.map((item) => { return (
{ - split(item.required).map((r) => { + item.required.map((r) => { return (
- } + {!isTagUsed &&
{translate('NoLinks')}
} { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_RELEASE_PROFILES]: createFetchHandler(section, '/releaseprofile'), + + [SAVE_RELEASE_PROFILE]: createSaveProviderHandler(section, '/releaseprofile'), + + [DELETE_RELEASE_PROFILE]: createRemoveItemHandler(section, '/releaseprofile') + }, + + // + // Reducers + + reducers: { + [SET_RELEASE_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/restrictions.js b/frontend/src/Store/Actions/Settings/restrictions.js deleted file mode 100644 index 7e0838593..000000000 --- a/frontend/src/Store/Actions/Settings/restrictions.js +++ /dev/null @@ -1,71 +0,0 @@ -import { createAction } from 'redux-actions'; -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -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'; - -// -// Variables - -const section = 'settings.restrictions'; - -// -// Actions Types - -export const FETCH_RESTRICTIONS = 'settings/restrictions/fetchRestrictions'; -export const SAVE_RESTRICTION = 'settings/restrictions/saveRestriction'; -export const DELETE_RESTRICTION = 'settings/restrictions/deleteRestriction'; -export const SET_RESTRICTION_VALUE = 'settings/restrictions/setRestrictionValue'; - -// -// Action Creators - -export const fetchRestrictions = createThunk(FETCH_RESTRICTIONS); -export const saveRestriction = createThunk(SAVE_RESTRICTION); -export const deleteRestriction = createThunk(DELETE_RESTRICTION); - -export const setRestrictionValue = createAction(SET_RESTRICTION_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - isSaving: false, - saveError: null, - items: [], - pendingChanges: {} - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_RESTRICTIONS]: createFetchHandler(section, '/restriction'), - - [SAVE_RESTRICTION]: createSaveProviderHandler(section, '/restriction'), - - [DELETE_RESTRICTION]: createRemoveItemHandler(section, '/restriction') - }, - - // - // Reducers - - reducers: { - [SET_RESTRICTION_VALUE]: createSetSettingValueReducer(section) - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index be1c621a5..afa649271 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -24,8 +24,8 @@ import namingExamples from './Settings/namingExamples'; import notifications from './Settings/notifications'; import qualityDefinitions from './Settings/qualityDefinitions'; import qualityProfiles from './Settings/qualityProfiles'; +import releaseProfiles from './Settings/releaseProfiles'; import remotePathMappings from './Settings/remotePathMappings'; -import restrictions from './Settings/restrictions'; import ui from './Settings/ui'; export * from './Settings/autoTaggingSpecifications'; @@ -52,7 +52,7 @@ export * from './Settings/notifications'; export * from './Settings/qualityDefinitions'; export * from './Settings/qualityProfiles'; export * from './Settings/remotePathMappings'; -export * from './Settings/restrictions'; +export * from './Settings/releaseProfiles'; export * from './Settings/ui'; // @@ -89,7 +89,7 @@ export const defaultState = { qualityDefinitions: qualityDefinitions.defaultState, qualityProfiles: qualityProfiles.defaultState, remotePathMappings: remotePathMappings.defaultState, - restrictions: restrictions.defaultState, + releaseProfiles: releaseProfiles.defaultState, ui: ui.defaultState }; @@ -135,7 +135,7 @@ export const actionHandlers = handleThunks({ ...qualityDefinitions.actionHandlers, ...qualityProfiles.actionHandlers, ...remotePathMappings.actionHandlers, - ...restrictions.actionHandlers, + ...releaseProfiles.actionHandlers, ...ui.actionHandlers }); @@ -172,7 +172,7 @@ export const reducers = createHandleActions({ ...qualityDefinitions.reducers, ...qualityProfiles.reducers, ...remotePathMappings.reducers, - ...restrictions.reducers, + ...releaseProfiles.reducers, ...ui.reducers }, defaultState, section); diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/229_update_restrictions_to_release_profilesFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/229_update_restrictions_to_release_profilesFixture.cs new file mode 100644 index 000000000..68305ea2a --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/229_update_restrictions_to_release_profilesFixture.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class update_restrictions_to_release_profilesFixture : MigrationTest + { + [Test] + public void should_migrate_required_ignored_columns_to_json_arrays() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Restrictions").Row(new + { + Required = "x265,1080p", + Ignored = "xvid,720p,480p", + Tags = new HashSet { }.ToJson() + }); + }); + + var items = db.Query("SELECT \"Required\", \"Ignored\" FROM \"ReleaseProfiles\""); + + items.Should().HaveCount(1); + items.First().Required.Should().BeEquivalentTo(new[] { "x265", "1080p" }); + items.First().Ignored.Should().BeEquivalentTo(new[] { "xvid", "720p", "480p" }); + } + + [Test] + public void should_delete_rows_with_empty_required_ignored_columns() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Restrictions").Row(new + { + Required = "", + Ignored = "", + Tags = new HashSet { }.ToJson() + }); + }); + + var items = db.Query("SELECT \"Required\", \"Ignored\" FROM \"ReleaseProfiles\""); + + items.Should().HaveCount(0); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs index 280c1dbbd..df7855376 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; +using System.Linq; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Movies; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.DecisionEngineTests @@ -30,16 +31,16 @@ namespace NzbDrone.Core.Test.DecisionEngineTests } }; - Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); } - private void GivenRestictions(string required, string ignored) + private void GivenRestictions(List required, List ignored) { - Mocker.GetMock() - .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List + Mocker.GetMock() + .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny())) + .Returns(new List { - new Restriction + new ReleaseProfile() { Required = required, Ignored = ignored @@ -50,9 +51,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_be_true_when_restrictions_are_empty() { - Mocker.GetMock() - .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List()); + Mocker.GetMock() + .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny())) + .Returns(new List()); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue(); } @@ -60,7 +61,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_be_true_when_title_contains_one_required_term() { - GivenRestictions("WEBRip", null); + GivenRestictions(new List { "WEBRip" }, new List()); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue(); } @@ -68,7 +69,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_be_false_when_title_does_not_contain_any_required_terms() { - GivenRestictions("doesnt,exist", null); + GivenRestictions(new List { "doesnt", "exist" }, new List()); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse(); } @@ -76,7 +77,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_be_true_when_title_does_not_contain_any_ignored_terms() { - GivenRestictions(null, "ignored"); + GivenRestictions(new List(), new List { "ignored" }); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue(); } @@ -84,7 +85,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_be_false_when_title_contains_one_anded_ignored_terms() { - GivenRestictions(null, "edited"); + GivenRestictions(new List(), new List { "edited" }); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse(); } @@ -95,7 +96,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestCase("X264,NOTTHERE")] public void should_ignore_case_when_matching_required(string required) { - GivenRestictions(required, null); + GivenRestictions(required.Split(',').ToList(), new List()); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue(); } @@ -106,7 +107,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestCase("X264,NOTTHERE")] public void should_ignore_case_when_matching_ignored(string ignored) { - GivenRestictions(null, ignored); + GivenRestictions(new List(), ignored.Split(',').ToList()); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse(); } @@ -116,11 +117,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { _remoteMovie.Release.Title = "[ www.Speed.cd ] -Whose.Line.is.it.Anyway.US.S10E24.720p.HDTV.x264-BAJSKORV"; - Mocker.GetMock() - .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List + Mocker.GetMock() + .Setup(s => s.EnabledForTags(It.IsAny>(), It.IsAny())) + .Returns(new List { - new Restriction { Required = "x264", Ignored = "www.Speed.cd" } + new ReleaseProfile + { + Required = new List { "x264" }, + Ignored = new List { "www.Speed.cd" } + } }); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse(); @@ -132,7 +137,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestCase(@"/\.WEB/", true)] public void should_match_perl_regex(string pattern, bool expected) { - GivenRestictions(pattern, null); + GivenRestictions(pattern.Split(',').ToList(), new List()); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().Be(expected); } diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs index 3fb5376e4..384ed1cd3 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs @@ -2,7 +2,7 @@ using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Housekeeping.Housekeepers; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Tags; using NzbDrone.Core.Test.Framework; @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers .BuildList(); Db.InsertMany(tags); - var restrictions = Builder.CreateListOfSize(2) + var restrictions = Builder.CreateListOfSize(2) .All() .With(v => v.Id = 0) .With(v => v.Tags.Add(tags[0].Id)) diff --git a/src/NzbDrone.Core/Datastore/Migration/229_update_restrictions_to_release_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/229_update_restrictions_to_release_profiles.cs new file mode 100644 index 000000000..88f59b150 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/229_update_restrictions_to_release_profiles.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using Dapper; +using FluentMigrator; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(229)] + public class update_restrictions_to_release_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Rename.Table("Restrictions").To("ReleaseProfiles"); + + Alter.Table("ReleaseProfiles").AddColumn("Name").AsString().Nullable().WithDefaultValue(null); + Alter.Table("ReleaseProfiles").AddColumn("Enabled").AsBoolean().WithDefaultValue(true); + Alter.Table("ReleaseProfiles").AddColumn("IndexerId").AsInt32().WithDefaultValue(0); + Delete.Column("Preferred").FromTable("ReleaseProfiles"); + + Execute.WithConnection(ChangeRequiredIgnoredTypes); + + Delete.FromTable("ReleaseProfiles").Row(new { Required = "[]", Ignored = "[]" }); + } + + // Update the Required and Ignored columns to be JSON arrays instead of comma separated strings + private void ChangeRequiredIgnoredTypes(IDbConnection conn, IDbTransaction tran) + { + var updatedReleaseProfiles = new List(); + + using (var getEmailCmd = conn.CreateCommand()) + { + getEmailCmd.Transaction = tran; + getEmailCmd.CommandText = "SELECT \"Id\", \"Required\", \"Ignored\" FROM \"ReleaseProfiles\""; + + using var reader = getEmailCmd.ExecuteReader(); + + while (reader.Read()) + { + var id = reader.GetInt32(0); + var requiredObj = reader.GetValue(1); + var ignoredObj = reader.GetValue(2); + + var required = requiredObj == DBNull.Value + ? Enumerable.Empty() + : requiredObj.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + var ignored = ignoredObj == DBNull.Value + ? Enumerable.Empty() + : ignoredObj.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + updatedReleaseProfiles.Add(new + { + Id = id, + Required = required.ToJson(), + Ignored = ignored.ToJson() + }); + } + } + + var updateReleaseProfilesSql = "UPDATE \"ReleaseProfiles\" SET \"Required\" = @Required, \"Ignored\" = @Ignored WHERE \"Id\" = @Id"; + conn.Execute(updateReleaseProfilesSql, updatedReleaseProfiles, transaction: tran); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 641c69a0e..69dccaf3e 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -37,9 +37,9 @@ using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; using NzbDrone.Core.Profiles.Delay; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Qualities; using NzbDrone.Core.RemotePathMappings; -using NzbDrone.Core.Restrictions; using NzbDrone.Core.RootFolders; using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; @@ -154,7 +154,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("RemotePathMappings").RegisterModel(); Mapper.Entity("Tags").RegisterModel(); - Mapper.Entity("Restrictions").RegisterModel(); + Mapper.Entity("ReleaseProfiles").RegisterModel(); Mapper.Entity("DelayProfiles").RegisterModel(); Mapper.Entity("Users").RegisterModel(); diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index 012943c96..042646e2d 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -173,8 +173,19 @@ namespace NzbDrone.Core.DecisionEngine private DownloadDecision GetDecisionForReport(RemoteMovie remoteMovie, SearchCriteriaBase searchCriteria = null) { - var reasons = _specifications.Select(c => EvaluateSpec(c, remoteMovie, searchCriteria)) - .Where(c => c != null); + var reasons = Array.Empty(); + + foreach (var specifications in _specifications.GroupBy(v => v.Priority).OrderBy(v => v.Key)) + { + reasons = specifications.Select(c => EvaluateSpec(c, remoteMovie, searchCriteria)) + .Where(c => c != null) + .ToArray(); + + if (reasons.Any()) + { + break; + } + } return new DownloadDecision(remoteMovie, reasons.ToArray()); } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs index 945baf2f3..232b42d61 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs @@ -1,25 +1,24 @@ -using System; using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.DecisionEngine.Specifications { public class ReleaseRestrictionsSpecification : IDecisionEngineSpecification { private readonly Logger _logger; - private readonly IRestrictionService _restrictionService; - private readonly ITermMatcher _termMatcher; + private readonly IReleaseProfileService _releaseProfileService; + private readonly ITermMatcherService _termMatcherService; - public ReleaseRestrictionsSpecification(ITermMatcher termMatcher, IRestrictionService restrictionService, Logger logger) + public ReleaseRestrictionsSpecification(ITermMatcherService termMatcherService, IReleaseProfileService releaseProfileService, Logger logger) { _logger = logger; - _restrictionService = restrictionService; - _termMatcher = termMatcher; + _releaseProfileService = releaseProfileService; + _termMatcherService = termMatcherService; } public SpecificationPriority Priority => SpecificationPriority.Default; @@ -30,14 +29,14 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger.Debug("Checking if release meets restrictions: {0}", subject); var title = subject.Release.Title; - var restrictions = _restrictionService.AllForTags(subject.Movie.Tags); + var releaseProfiles = _releaseProfileService.EnabledForTags(subject.Movie.Tags, subject.Release.IndexerId); - var required = restrictions.Where(r => r.Required.IsNotNullOrWhiteSpace()); - var ignored = restrictions.Where(r => r.Ignored.IsNotNullOrWhiteSpace()); + var required = releaseProfiles.Where(r => r.Required.Any()); + var ignored = releaseProfiles.Where(r => r.Ignored.Any()); foreach (var r in required) { - var requiredTerms = r.Required.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); + var requiredTerms = r.Required; var foundTerms = ContainsAny(requiredTerms, title); if (foundTerms.Empty()) @@ -50,7 +49,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications foreach (var r in ignored) { - var ignoredTerms = r.Ignored.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); + var ignoredTerms = r.Ignored; var foundTerms = ContainsAny(ignoredTerms, title); if (foundTerms.Any()) @@ -67,7 +66,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications private List ContainsAny(List terms, string title) { - return terms.Where(t => _termMatcher.IsMatch(t, title)).ToList(); + return terms.Where(t => _termMatcherService.IsMatch(t, title)).ToList(); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index 205f9b5a5..e6d382cd7 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { using var mapper = _database.OpenConnection(); - var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "Restrictions", "ImportLists", "Indexers", "AutoTagging", "DownloadClients" } + var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging", "DownloadClients" } .SelectMany(v => GetUsedTags(v, mapper)) .Distinct() .ToList(); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 7148f73c0..143b392b6 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -789,7 +789,7 @@ "PrioritySettings": "Priority: {priority}", "ProcessingFolders": "Processing Folders", "Profiles": "Profiles", - "ProfilesSettingsSummary": "Quality, Language and Delay profiles", + "ProfilesSettingsSummary": "Quality, Language, Delay and Release profiles", "Progress": "Progress", "Proper": "Proper", "Protocol": "Protocol", diff --git a/src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs b/src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs similarity index 97% rename from src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs rename to src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs index 52806c80d..b75e80db9 100644 --- a/src/NzbDrone.Core/Restrictions/PerlRegexFactory.cs +++ b/src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs @@ -1,7 +1,7 @@ -using System; +using System; using System.Text.RegularExpressions; -namespace NzbDrone.Core.Restrictions +namespace NzbDrone.Core.Profiles.Releases { public static class PerlRegexFactory { diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs new file mode 100644 index 000000000..75e23fab9 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Profiles.Releases +{ + public class ReleaseProfile : ModelBase + { + public string Name { get; set; } + public bool Enabled { get; set; } + public List Required { get; set; } + public List Ignored { get; set; } + public int IndexerId { get; set; } + public HashSet Tags { get; set; } + + public ReleaseProfile() + { + Enabled = true; + Required = new List(); + Ignored = new List(); + Tags = new HashSet(); + IndexerId = 0; + } + } + + public class ReleaseProfilePreferredComparer : IComparer> + { + public int Compare(KeyValuePair x, KeyValuePair y) + { + return y.Value.CompareTo(x.Value); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs new file mode 100644 index 000000000..6ba41e751 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface IReleaseProfileRepository : IBasicRepository + { + } + + public class ReleaseProfileRepository : BasicRepository, IReleaseProfileRepository + { + public ReleaseProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs new file mode 100644 index 000000000..890170054 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface IReleaseProfileService + { + List All(); + List AllForTag(int tagId); + List AllForTags(HashSet tagIds); + List EnabledForTags(HashSet tagIds, int indexerId); + ReleaseProfile Get(int id); + void Delete(int id); + ReleaseProfile Add(ReleaseProfile restriction); + ReleaseProfile Update(ReleaseProfile restriction); + } + + public class ReleaseProfileService : IReleaseProfileService + { + private readonly IReleaseProfileRepository _repo; + private readonly Logger _logger; + + public ReleaseProfileService(IReleaseProfileRepository repo, Logger logger) + { + _repo = repo; + _logger = logger; + } + + public List All() + { + var all = _repo.All().ToList(); + + return all; + } + + public List AllForTag(int tagId) + { + return _repo.All().Where(r => r.Tags.Contains(tagId)).ToList(); + } + + public List AllForTags(HashSet tagIds) + { + return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); + } + + public List EnabledForTags(HashSet tagIds, int indexerId) + { + return AllForTags(tagIds) + .Where(r => r.Enabled) + .Where(r => r.IndexerId == indexerId || r.IndexerId == 0).ToList(); + } + + public ReleaseProfile Get(int id) + { + return _repo.Get(id); + } + + public void Delete(int id) + { + _repo.Delete(id); + } + + public ReleaseProfile Add(ReleaseProfile restriction) + { + return _repo.Insert(restriction); + } + + public ReleaseProfile Update(ReleaseProfile restriction) + { + return _repo.Update(restriction); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/TermMatcherService.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatcherService.cs new file mode 100644 index 000000000..69df8e450 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatcherService.cs @@ -0,0 +1,49 @@ +using System; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Profiles.Releases.TermMatchers; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface ITermMatcherService + { + bool IsMatch(string term, string value); + string MatchingTerm(string term, string value); + } + + public class TermMatcherService : ITermMatcherService + { + private ICached _matcherCache; + + public TermMatcherService(ICacheManager cacheManager) + { + _matcherCache = cacheManager.GetCache(GetType()); + } + + public bool IsMatch(string term, string value) + { + return GetMatcher(term).IsMatch(value); + } + + public string MatchingTerm(string term, string value) + { + return GetMatcher(term).MatchingTerm(value); + } + + public ITermMatcher GetMatcher(string term) + { + return _matcherCache.Get(term, () => CreateMatcherInternal(term), TimeSpan.FromHours(24)); + } + + private ITermMatcher CreateMatcherInternal(string term) + { + if (PerlRegexFactory.TryCreateRegex(term, out var regex)) + { + return new RegexTermMatcher(regex); + } + else + { + return new CaseInsensitiveTermMatcher(term); + } + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/TermMatchers/CaseInsensitiveTermMatcher.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/CaseInsensitiveTermMatcher.cs new file mode 100644 index 000000000..21dad670e --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/CaseInsensitiveTermMatcher.cs @@ -0,0 +1,29 @@ +namespace NzbDrone.Core.Profiles.Releases.TermMatchers +{ + public sealed class CaseInsensitiveTermMatcher : ITermMatcher + { + private readonly string _originalTerm; + private readonly string _term; + + public CaseInsensitiveTermMatcher(string term) + { + _originalTerm = term; + _term = term.ToLowerInvariant(); + } + + public bool IsMatch(string value) + { + return value.ToLowerInvariant().Contains(_term); + } + + public string MatchingTerm(string value) + { + if (value.ToLowerInvariant().Contains(_term)) + { + return _originalTerm; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/TermMatchers/ITermMatcher.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/ITermMatcher.cs new file mode 100644 index 000000000..0122372ab --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/ITermMatcher.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Profiles.Releases.TermMatchers +{ + public interface ITermMatcher + { + bool IsMatch(string value); + string MatchingTerm(string value); + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/TermMatchers/RegexTermMatcher.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/RegexTermMatcher.cs new file mode 100644 index 000000000..39125bb69 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/RegexTermMatcher.cs @@ -0,0 +1,24 @@ +using System.Text.RegularExpressions; + +namespace NzbDrone.Core.Profiles.Releases.TermMatchers +{ + public class RegexTermMatcher : ITermMatcher + { + private readonly Regex _regex; + + public RegexTermMatcher(Regex regex) + { + _regex = regex; + } + + public bool IsMatch(string value) + { + return _regex.IsMatch(value); + } + + public string MatchingTerm(string value) + { + return _regex.Match(value).Value; + } + } +} diff --git a/src/NzbDrone.Core/Restrictions/Restriction.cs b/src/NzbDrone.Core/Restrictions/Restriction.cs deleted file mode 100644 index 9be667d81..000000000 --- a/src/NzbDrone.Core/Restrictions/Restriction.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Restrictions -{ - public class Restriction : ModelBase - { - public string Required { get; set; } - public string Preferred { get; set; } - public string Ignored { get; set; } - public HashSet Tags { get; set; } - - public Restriction() - { - Tags = new HashSet(); - } - } -} diff --git a/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs b/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs deleted file mode 100644 index a88b0e67f..000000000 --- a/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs +++ /dev/null @@ -1,17 +0,0 @@ -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Messaging.Events; - -namespace NzbDrone.Core.Restrictions -{ - public interface IRestrictionRepository : IBasicRepository - { - } - - public class RestrictionRepository : BasicRepository, IRestrictionRepository - { - public RestrictionRepository(IMainDatabase database, IEventAggregator eventAggregator) - : base(database, eventAggregator) - { - } - } -} diff --git a/src/NzbDrone.Core/Restrictions/RestrictionService.cs b/src/NzbDrone.Core/Restrictions/RestrictionService.cs deleted file mode 100644 index 561add650..000000000 --- a/src/NzbDrone.Core/Restrictions/RestrictionService.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Core.Restrictions -{ - public interface IRestrictionService - { - List All(); - List AllForTag(int tagId); - List AllForTags(HashSet tagIds); - Restriction Get(int id); - void Delete(int id); - Restriction Add(Restriction restriction); - Restriction Update(Restriction restriction); - } - - public class RestrictionService : IRestrictionService - { - private readonly IRestrictionRepository _repo; - private readonly Logger _logger; - - public RestrictionService(IRestrictionRepository repo, Logger logger) - { - _repo = repo; - _logger = logger; - } - - public List All() - { - return _repo.All().ToList(); - } - - public List AllForTag(int tagId) - { - return _repo.All().Where(r => r.Tags.Contains(tagId)).ToList(); - } - - public List AllForTags(HashSet tagIds) - { - return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); - } - - public Restriction Get(int id) - { - return _repo.Get(id); - } - - public void Delete(int id) - { - _repo.Delete(id); - } - - public Restriction Add(Restriction restriction) - { - return _repo.Insert(restriction); - } - - public Restriction Update(Restriction restriction) - { - return _repo.Update(restriction); - } - } -} diff --git a/src/NzbDrone.Core/Restrictions/TermMatcher.cs b/src/NzbDrone.Core/Restrictions/TermMatcher.cs deleted file mode 100644 index 3bc40c3cf..000000000 --- a/src/NzbDrone.Core/Restrictions/TermMatcher.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using NzbDrone.Common.Cache; - -namespace NzbDrone.Core.Restrictions -{ - public interface ITermMatcher - { - bool IsMatch(string term, string value); - } - - public class TermMatcher : ITermMatcher - { - private ICached> _matcherCache; - - public TermMatcher(ICacheManager cacheManager) - { - _matcherCache = cacheManager.GetCache>(GetType()); - } - - public bool IsMatch(string term, string value) - { - return GetMatcher(term)(value); - } - - public Predicate GetMatcher(string term) - { - return _matcherCache.Get(term, () => CreateMatcherInternal(term), TimeSpan.FromHours(24)); - } - - private Predicate CreateMatcherInternal(string term) - { - if (PerlRegexFactory.TryCreateRegex(term, out var regex)) - { - return regex.IsMatch; - } - else - { - return new CaseInsensitiveTermMatcher(term).IsMatch; - } - } - - private sealed class CaseInsensitiveTermMatcher - { - private readonly string _term; - - public CaseInsensitiveTermMatcher(string term) - { - _term = term.ToLowerInvariant(); - } - - public bool IsMatch(string value) - { - return value.ToLowerInvariant().Contains(_term); - } - } - } -} diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index d26acc97d..4abce88b7 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -9,7 +9,7 @@ namespace NzbDrone.Core.Tags public string Label { get; set; } public List MovieIds { get; set; } public List NotificationIds { get; set; } - public List RestrictionIds { get; set; } + public List ReleaseProfileIds { get; set; } public List DelayProfileIds { get; set; } public List ImportListIds { get; set; } public List IndexerIds { get; set; } @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Tags public bool InUse => MovieIds.Any() || NotificationIds.Any() || - RestrictionIds.Any() || + ReleaseProfileIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any() || diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index 0eebd3331..48e519940 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -9,7 +9,7 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Movies; using NzbDrone.Core.Notifications; using NzbDrone.Core.Profiles.Delay; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.Tags { @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Tags private readonly IDelayProfileService _delayProfileService; private readonly IImportListFactory _importListFactory; private readonly INotificationFactory _notificationFactory; - private readonly IRestrictionService _restrictionService; + private readonly IReleaseProfileService _releaseProfileService; private readonly IMovieService _movieService; private readonly IIndexerFactory _indexerService; private readonly IAutoTaggingService _autoTaggingService; @@ -44,7 +44,7 @@ namespace NzbDrone.Core.Tags IDelayProfileService delayProfileService, IImportListFactory importListFactory, INotificationFactory notificationFactory, - IRestrictionService restrictionService, + IReleaseProfileService releaseProfileService, IMovieService movieService, IIndexerFactory indexerService, IAutoTaggingService autoTaggingService, @@ -55,7 +55,7 @@ namespace NzbDrone.Core.Tags _delayProfileService = delayProfileService; _importListFactory = importListFactory; _notificationFactory = notificationFactory; - _restrictionService = restrictionService; + _releaseProfileService = releaseProfileService; _movieService = movieService; _indexerService = indexerService; _autoTaggingService = autoTaggingService; @@ -90,7 +90,7 @@ namespace NzbDrone.Core.Tags var delayProfiles = _delayProfileService.AllForTag(tagId); var importLists = _importListFactory.AllForTag(tagId); var notifications = _notificationFactory.AllForTag(tagId); - var restrictions = _restrictionService.AllForTag(tagId); + var releaseProfiles = _releaseProfileService.AllForTag(tagId); var movies = _movieService.AllMovieTags().Where(x => x.Value.Contains(tagId)).Select(x => x.Key).ToList(); var indexers = _indexerService.AllForTag(tagId); var autoTags = _autoTaggingService.AllForTag(tagId); @@ -103,7 +103,7 @@ namespace NzbDrone.Core.Tags DelayProfileIds = delayProfiles.Select(c => c.Id).ToList(), ImportListIds = importLists.Select(c => c.Id).ToList(), NotificationIds = notifications.Select(c => c.Id).ToList(), - RestrictionIds = restrictions.Select(c => c.Id).ToList(), + ReleaseProfileIds = releaseProfiles.Select(c => c.Id).ToList(), MovieIds = movies, IndexerIds = indexers.Select(c => c.Id).ToList(), AutoTagIds = autoTags.Select(c => c.Id).ToList(), @@ -117,7 +117,7 @@ namespace NzbDrone.Core.Tags var delayProfiles = _delayProfileService.All(); var importLists = _importListFactory.All(); var notifications = _notificationFactory.All(); - var restrictions = _restrictionService.All(); + var releaseProfiles = _releaseProfileService.All(); var movies = _movieService.AllMovieTags(); var indexers = _indexerService.All(); var autotags = _autoTaggingService.All(); @@ -134,7 +134,7 @@ namespace NzbDrone.Core.Tags DelayProfileIds = delayProfiles.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), ImportListIds = importLists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), - RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + ReleaseProfileIds = releaseProfiles.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), MovieIds = movies.Where(c => c.Value.Contains(tag.Id)).Select(c => c.Key).ToList(), IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), AutoTagIds = autotags.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), diff --git a/src/Radarr.Api.V3/Profiles/Release/ReleaseProfileController.cs b/src/Radarr.Api.V3/Profiles/Release/ReleaseProfileController.cs new file mode 100644 index 000000000..cbdbfbb8d --- /dev/null +++ b/src/Radarr.Api.V3/Profiles/Release/ReleaseProfileController.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Profiles.Releases; +using Radarr.Http; +using Radarr.Http.REST; +using Radarr.Http.REST.Attributes; + +namespace Radarr.Api.V3.Profiles.Release +{ + [V3ApiController] + public class ReleaseProfileController : RestController + { + private readonly IReleaseProfileService _profileService; + private readonly IIndexerFactory _indexerFactory; + + public ReleaseProfileController(IReleaseProfileService profileService, IIndexerFactory indexerFactory) + { + _profileService = profileService; + _indexerFactory = indexerFactory; + + SharedValidator.RuleFor(d => d).Custom((restriction, context) => + { + if (restriction.MapIgnored().Empty() && restriction.MapRequired().Empty()) + { + context.AddFailure("'Must contain' or 'Must not contain' is required"); + } + + if (restriction.Enabled && restriction.IndexerId != 0 && !_indexerFactory.Exists(restriction.IndexerId)) + { + context.AddFailure(nameof(ReleaseProfile.IndexerId), "Indexer does not exist"); + } + }); + } + + [RestPostById] + public ActionResult Create(ReleaseProfileResource 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(ReleaseProfileResource resource) + { + var model = resource.ToModel(); + + _profileService.Update(model); + + return Accepted(model.Id); + } + + protected override ReleaseProfileResource GetResourceById(int id) + { + return _profileService.Get(id).ToResource(); + } + + [HttpGet] + public List GetAll() + { + return _profileService.All().ToResource(); + } + } +} diff --git a/src/Radarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs b/src/Radarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs new file mode 100644 index 000000000..beb10323f --- /dev/null +++ b/src/Radarr.Api.V3/Profiles/Release/ReleaseProfileResource.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using NzbDrone.Core.Profiles.Releases; +using Radarr.Http.REST; + +namespace Radarr.Api.V3.Profiles.Release +{ + public class ReleaseProfileResource : RestResource + { + public string Name { get; set; } + public bool Enabled { get; set; } + + // Is List, string or JArray, we accept 'string' with POST and PUT for backwards compatibility + public object Required { get; set; } + public object Ignored { get; set; } + public int IndexerId { get; set; } + public HashSet Tags { get; set; } + + public ReleaseProfileResource() + { + Tags = new HashSet(); + } + } + + public static class RestrictionResourceMapper + { + public static ReleaseProfileResource ToResource(this ReleaseProfile model) + { + if (model == null) + { + return null; + } + + return new ReleaseProfileResource + { + Id = model.Id, + Name = model.Name, + Enabled = model.Enabled, + Required = model.Required ?? new List(), + Ignored = model.Ignored ?? new List(), + IndexerId = model.IndexerId, + Tags = new HashSet(model.Tags) + }; + } + + public static ReleaseProfile ToModel(this ReleaseProfileResource resource) + { + if (resource == null) + { + return null; + } + + return new ReleaseProfile + { + Id = resource.Id, + Name = resource.Name, + Enabled = resource.Enabled, + Required = resource.MapRequired(), + Ignored = resource.MapIgnored(), + IndexerId = resource.IndexerId, + Tags = new HashSet(resource.Tags) + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + + public static List MapRequired(this ReleaseProfileResource profile) => ParseArray(profile.Required, "required"); + public static List MapIgnored(this ReleaseProfileResource profile) => ParseArray(profile.Ignored, "ignored"); + + private static List ParseArray(object resource, string title) + { + if (resource == null) + { + return new List(); + } + + if (resource is List list) + { + return list; + } + + if (resource is JsonElement array) + { + if (array.ValueKind == JsonValueKind.String) + { + return array.GetString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); + } + + if (array.ValueKind == JsonValueKind.Array) + { + return JsonSerializer.Deserialize>(array); + } + } + + if (resource is string str) + { + return str.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); + } + + throw new BadRequestException($"Invalid field {title}, should be string or string array"); + } + } +} diff --git a/src/Radarr.Api.V3/Restrictions/RestrictionController.cs b/src/Radarr.Api.V3/Restrictions/RestrictionController.cs deleted file mode 100644 index b71ad3836..000000000 --- a/src/Radarr.Api.V3/Restrictions/RestrictionController.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Generic; -using FluentValidation; -using Microsoft.AspNetCore.Mvc; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Restrictions; -using Radarr.Http; -using Radarr.Http.REST; -using Radarr.Http.REST.Attributes; - -namespace Radarr.Api.V3.Restrictions -{ - [V3ApiController] - public class RestrictionController : RestController - { - private readonly IRestrictionService _restrictionService; - - public RestrictionController(IRestrictionService restrictionService) - { - _restrictionService = restrictionService; - - SharedValidator.RuleFor(d => d).Custom((restriction, context) => - { - if (restriction.Ignored.IsNullOrWhiteSpace() && restriction.Required.IsNullOrWhiteSpace()) - { - context.AddFailure("Either 'Must contain' or 'Must not contain' is required"); - } - }); - } - - protected override RestrictionResource GetResourceById(int id) - { - return _restrictionService.Get(id).ToResource(); - } - - [HttpGet] - public List GetAll() - { - return _restrictionService.All().ToResource(); - } - - [RestPostById] - public ActionResult Create(RestrictionResource resource) - { - return Created(_restrictionService.Add(resource.ToModel()).Id); - } - - [RestPutById] - public ActionResult Update(RestrictionResource resource) - { - _restrictionService.Update(resource.ToModel()); - return Accepted(resource.Id); - } - - [RestDeleteById] - public void DeleteRestriction(int id) - { - _restrictionService.Delete(id); - } - } -} diff --git a/src/Radarr.Api.V3/Restrictions/RestrictionResource.cs b/src/Radarr.Api.V3/Restrictions/RestrictionResource.cs deleted file mode 100644 index c40cb43b3..000000000 --- a/src/Radarr.Api.V3/Restrictions/RestrictionResource.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Restrictions; -using Radarr.Http.REST; - -namespace Radarr.Api.V3.Restrictions -{ - public class RestrictionResource : RestResource - { - public string Required { get; set; } - public string Preferred { get; set; } - public string Ignored { get; set; } - public HashSet Tags { get; set; } - - public RestrictionResource() - { - Tags = new HashSet(); - } - } - - public static class RestrictionResourceMapper - { - public static RestrictionResource ToResource(this Restriction model) - { - if (model == null) - { - return null; - } - - return new RestrictionResource - { - Id = model.Id, - - Required = model.Required, - Preferred = model.Preferred, - Ignored = model.Ignored, - Tags = new HashSet(model.Tags) - }; - } - - public static Restriction ToModel(this RestrictionResource resource) - { - if (resource == null) - { - return null; - } - - return new Restriction - { - Id = resource.Id, - - Required = resource.Required, - Preferred = resource.Preferred, - Ignored = resource.Ignored, - Tags = new HashSet(resource.Tags) - }; - } - - public static List ToResource(this IEnumerable models) - { - return models.Select(ToResource).ToList(); - } - } -} diff --git a/src/Radarr.Api.V3/Tags/TagDetailsResource.cs b/src/Radarr.Api.V3/Tags/TagDetailsResource.cs index 7b57975e2..f55034f3c 100644 --- a/src/Radarr.Api.V3/Tags/TagDetailsResource.cs +++ b/src/Radarr.Api.V3/Tags/TagDetailsResource.cs @@ -11,7 +11,7 @@ namespace Radarr.Api.V3.Tags public List DelayProfileIds { get; set; } public List ImportListIds { get; set; } public List NotificationIds { get; set; } - public List RestrictionIds { get; set; } + public List ReleaseProfileIds { get; set; } public List IndexerIds { get; set; } public List DownloadClientIds { get; set; } public List AutoTagIds { get; set; } @@ -34,7 +34,7 @@ namespace Radarr.Api.V3.Tags DelayProfileIds = model.DelayProfileIds, ImportListIds = model.ImportListIds, NotificationIds = model.NotificationIds, - RestrictionIds = model.RestrictionIds, + ReleaseProfileIds = model.ReleaseProfileIds, IndexerIds = model.IndexerIds, DownloadClientIds = model.DownloadClientIds, AutoTagIds = model.AutoTagIds,