From 241bf85f15c13ee1c569cbae0998345e545cf793 Mon Sep 17 00:00:00 2001 From: ta264 Date: Tue, 18 Feb 2020 21:03:05 +0000 Subject: [PATCH] New: Better interface for creating custom formats --- .../CustomFormats/CustomFormat.js | 13 +- .../CustomFormats/CustomFormats.js | 17 +- .../CustomFormats/CustomFormatsConnector.js | 10 +- .../EditCustomFormatModalContent.css | 17 + .../EditCustomFormatModalContent.js | 145 ++++++-- .../EditCustomFormatModalContentConnector.js | 36 +- .../Specifications/AddSpecificationItem.css | 44 +++ .../Specifications/AddSpecificationItem.js | 110 ++++++ .../Specifications/AddSpecificationModal.js | 25 ++ .../AddSpecificationModalContent.css | 5 + .../AddSpecificationModalContent.js | 93 ++++++ .../AddSpecificationModalContentConnector.js | 70 ++++ .../AddSpecificationPresetMenuItem.js | 49 +++ .../Specifications/EditSpecificationModal.js | 27 ++ .../EditSpecificationModalConnector.js | 50 +++ .../EditSpecificationModalContent.css | 5 + .../EditSpecificationModalContent.js | 154 +++++++++ .../EditSpecificationModalContentConnector.js | 78 +++++ .../Specifications/Specification.css | 38 +++ .../Specifications/Specification.js | 145 ++++++++ ...EditQualityProfileModalContentConnector.js | 16 +- .../Quality/QualityProfileFormatItems.js | 8 +- .../Settings/customFormatSpecifications.js | 184 ++++++++++ .../Store/Actions/Settings/customFormats.js | 28 +- frontend/src/Store/Actions/settingsActions.js | 5 + frontend/src/Utilities/State/getNextId.js | 5 + .../src/Utilities/State/getProviderState.js | 17 +- .../CustomFormats/CustomFormatModule.cs | 67 ++++ .../CustomFormatResource.cs | 18 +- src/NzbDrone.Api/Profiles/ProfileResource.cs | 2 +- .../Qualities/CustomFormatModule.cs | 137 -------- .../Qualities/FormatTagMatchResultResource.cs | 64 ---- .../Qualities/FormatTagValidator.cs | 46 --- .../CustomFormatComparerFixture.cs | 9 +- .../CustomFormats/QualityTagFixture.cs | 69 ---- .../Migration/168_custom_format_rework.cs | 117 +++++++ .../CutoffSpecificationFixture.cs | 3 +- .../PrioritizeDownloadDecisionFixture.cs | 5 +- .../QualityUpgradeSpecificationFixture.cs | 5 +- .../Annotations}/SelectOption.cs | 2 +- .../Annotations/SelectOptionsConverter.cs | 9 + .../CustomFormats/CustomFormat.cs | 8 +- .../CustomFormatCalculationService.cs | 85 ++--- .../CustomFormats/CustomFormatMatchResult.cs | 14 - .../CustomFormats/CustomFormatService.cs | 29 -- src/NzbDrone.Core/CustomFormats/FormatTag.cs | 313 ------------------ .../CustomFormats/FormatTagMatchesGroup.cs | 15 - .../SpecificationMatchesGroup.cs | 13 + .../CustomFormatSpecificationBase.cs | 34 ++ .../Specifications/EditionSpecification.cs | 16 + .../ICustomFormatSpecification.cs | 17 + .../IndexerFlagSpecification.cs | 22 ++ .../Specifications/LanguageSpecification.cs | 20 ++ .../QualityModifierSpecification.cs | 20 ++ .../Specifications/RegexSpecificationBase.cs | 32 ++ .../ReleaseTitleSpecification.cs | 20 ++ .../Specifications/ResolutionSpecification.cs | 20 ++ .../Specifications/SizeSpecification.cs | 26 ++ .../Specifications/SourceSpecification.cs | 20 ++ .../CustomFormatSpecificationConverter.cs | 74 +++++ .../Converters/QualityTagStringConverter.cs | 41 --- .../Migration/168_custom_format_rework.cs | 222 +++++++++++++ src/NzbDrone.Core/Datastore/TableMapping.cs | 2 +- .../Languages/LanguageFieldConverter.cs | 13 + src/NzbDrone.Core/Profiles/ProfileService.cs | 4 +- .../CustomFormats/CustomFormatModule.cs | 111 +++---- .../CustomFormats/CustomFormatResource.cs | 20 +- .../CustomFormatSpecificationSchema.cs | 36 ++ .../FormatTagMatchResultResource.cs | 64 ---- .../CustomFormats/FormatTagValidator.cs | 46 --- .../Profiles/Quality/QualityProfileModule.cs | 2 +- .../Quality/QualityProfileResource.cs | 9 +- src/Radarr.Http/ClientSchema/Field.cs | 1 + src/Radarr.Http/ClientSchema/SchemaBuilder.cs | 17 +- 74 files changed, 2251 insertions(+), 1082 deletions(-) create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationItem.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationItem.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModal.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContent.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContent.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContentConnector.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationPresetMenuItem.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModal.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalConnector.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContentConnector.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.js create mode 100644 frontend/src/Store/Actions/Settings/customFormatSpecifications.js create mode 100644 frontend/src/Utilities/State/getNextId.js create mode 100644 src/NzbDrone.Api/CustomFormats/CustomFormatModule.cs rename src/NzbDrone.Api/{Qualities => CustomFormats}/CustomFormatResource.cs (79%) delete mode 100644 src/NzbDrone.Api/Qualities/CustomFormatModule.cs delete mode 100644 src/NzbDrone.Api/Qualities/FormatTagMatchResultResource.cs delete mode 100644 src/NzbDrone.Api/Qualities/FormatTagValidator.cs delete mode 100644 src/NzbDrone.Core.Test/CustomFormats/QualityTagFixture.cs create mode 100644 src/NzbDrone.Core.Test/Datastore/Migration/168_custom_format_rework.cs rename src/{Radarr.Http/ClientSchema => NzbDrone.Core/Annotations}/SelectOption.cs (76%) create mode 100644 src/NzbDrone.Core/Annotations/SelectOptionsConverter.cs delete mode 100644 src/NzbDrone.Core/CustomFormats/CustomFormatMatchResult.cs delete mode 100644 src/NzbDrone.Core/CustomFormats/FormatTag.cs delete mode 100644 src/NzbDrone.Core/CustomFormats/FormatTagMatchesGroup.cs create mode 100644 src/NzbDrone.Core/CustomFormats/SpecificationMatchesGroup.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/CustomFormatSpecificationBase.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/EditionSpecification.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/ICustomFormatSpecification.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/LanguageSpecification.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/QualityModifierSpecification.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/RegexSpecificationBase.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/ReleaseTitleSpecification.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/ResolutionSpecification.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/SizeSpecification.cs create mode 100644 src/NzbDrone.Core/CustomFormats/Specifications/SourceSpecification.cs create mode 100644 src/NzbDrone.Core/Datastore/Converters/CustomFormatSpecificationConverter.cs delete mode 100644 src/NzbDrone.Core/Datastore/Converters/QualityTagStringConverter.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/168_custom_format_rework.cs create mode 100644 src/NzbDrone.Core/Languages/LanguageFieldConverter.cs create mode 100644 src/Radarr.Api.V3/CustomFormats/CustomFormatSpecificationSchema.cs delete mode 100644 src/Radarr.Api.V3/CustomFormats/FormatTagMatchResultResource.cs delete mode 100644 src/Radarr.Api.V3/CustomFormats/FormatTagValidator.cs diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js index 974d4b5b5..49f4463de 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import split from 'Utilities/String/split'; import { icons, kinds } from 'Helpers/Props'; import Card from 'Components/Card'; import Label from 'Components/Label'; @@ -65,7 +64,7 @@ class CustomFormat extends Component { const { id, name, - formatTags, + specifications, isDeleting } = this.props; @@ -90,17 +89,17 @@ class CustomFormat extends Component {
{ - split(formatTags).map((item) => { + specifications.map((item, index) => { if (!item) { return null; } return ( ); }) @@ -138,7 +137,7 @@ class CustomFormat extends Component { CustomFormat.propTypes = { id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, - formatTags: PropTypes.string.isRequired, + specifications: PropTypes.arrayOf(PropTypes.object).isRequired, isDeleting: PropTypes.bool.isRequired, onConfirmDeleteCustomFormat: PropTypes.func.isRequired, onCloneCustomFormatPress: PropTypes.func.isRequired diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormats.js b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormats.js index 9bc6f90d5..2cc2a41ca 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormats.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormats.js @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import sortByName from 'Utilities/Array/sortByName'; import { icons } from 'Helpers/Props'; import FieldSet from 'Components/FieldSet'; import Card from 'Components/Card'; @@ -19,7 +18,8 @@ class CustomFormats extends Component { super(props, context); this.state = { - isCustomFormatModalOpen: false + isCustomFormatModalOpen: false, + tagsFromId: undefined }; } @@ -28,7 +28,10 @@ class CustomFormats extends Component { onCloneCustomFormatPress = (id) => { this.props.onCloneCustomFormatPress(id); - this.setState({ isCustomFormatModalOpen: true }); + this.setState({ + isCustomFormatModalOpen: true, + tagsFromId: id + }); } onEditCustomFormatPress = () => { @@ -36,7 +39,10 @@ class CustomFormats extends Component { } onModalClose = () => { - this.setState({ isCustomFormatModalOpen: false }); + this.setState({ + isCustomFormatModalOpen: false, + tagsFromId: undefined + }); } // @@ -59,7 +65,7 @@ class CustomFormats extends Component { >
{ - items.sort(sortByName).map((item) => { + items.map((item) => { return ( diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js index ee5f820c4..bbbb57661 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js @@ -2,17 +2,15 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import sortByName from 'Utilities/Array/sortByName'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import { fetchCustomFormats, deleteCustomFormat, cloneCustomFormat } from 'Store/Actions/settingsActions'; import CustomFormats from './CustomFormats'; function createMapStateToProps() { return createSelector( - (state) => state.settings.customFormats, - (customFormats) => { - return { - ...customFormats - }; - } + createSortedSectionSelector('settings.customFormats', sortByName), + (customFormats) => customFormats ); } diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css index a2b6014df..cdf21ca8a 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css @@ -3,3 +3,20 @@ margin-right: auto; } + +.addSpecification { + composes: customFormat from '~./CustomFormat.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/CustomFormats/CustomFormats/EditCustomFormatModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js index 879c143aa..94518d4db 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js @@ -1,6 +1,9 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { inputTypes, kinds } from 'Helpers/Props'; +import { icons, inputTypes, kinds } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -12,10 +15,43 @@ import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; import FormInputGroup from 'Components/Form/FormInputGroup'; +import Specification from './Specifications/Specification'; +import AddSpecificationModal from './Specifications/AddSpecificationModal'; +import EditSpecificationModalConnector from './Specifications/EditSpecificationModalConnector'; import styles from './EditCustomFormatModalContent.css'; class EditCustomFormatModalContent extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddSpecificationModalOpen: false, + isEditSpecificationModalOpen: false + }; + } + + // + // Listeners + + onAddSpecificationPress = () => { + this.setState({ isAddSpecificationModalOpen: true }); + } + + onAddSpecificationModalClose = ({ specificationSelected = false } = {}) => { + this.setState({ + isAddSpecificationModalOpen: false, + isEditSpecificationModalOpen: specificationSelected + }); + } + + onEditSpecificationModalClose = () => { + this.setState({ isEditSpecificationModalOpen: false }); + } + // // Render @@ -26,17 +62,25 @@ class EditCustomFormatModalContent extends Component { isSaving, saveError, item, + specificationsPopulated, + specifications, onInputChange, onSavePress, onModalClose, onDeleteCustomFormatPress, + onCloneSpecificationPress, + onConfirmDeleteSpecification, ...otherProps } = this.props; + const { + isAddSpecificationModalOpen, + isEditSpecificationModalOpen + } = this.state; + const { id, - name, - formatTags + name } = item; return ( @@ -59,37 +103,64 @@ class EditCustomFormatModalContent extends Component { } { - !isFetching && !error && -
- - - Name - - - - - - - - Format Tags - - - - -
- + !isFetching && !error && specificationsPopulated && +
+
+ + + Name + + + + +
+ +
+
+ { + specifications.map((tag) => { + return ( + + ); + }) + } + + +
+ +
+
+
+
+ + + + +
}
@@ -130,11 +201,15 @@ EditCustomFormatModalContent.propTypes = { isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, item: PropTypes.object.isRequired, + specificationsPopulated: PropTypes.bool.isRequired, + specifications: PropTypes.arrayOf(PropTypes.object), onInputChange: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired, onContentHeightChange: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired, - onDeleteCustomFormatPress: PropTypes.func + onDeleteCustomFormatPress: PropTypes.func, + onCloneSpecificationPress: PropTypes.func.isRequired, + onConfirmDeleteSpecification: PropTypes.func.isRequired }; export default EditCustomFormatModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContentConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContentConnector.js index c73fcf174..b3de4d440 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContentConnector.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContentConnector.js @@ -3,17 +3,20 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; -import { setCustomFormatValue, saveCustomFormat } from 'Store/Actions/settingsActions'; +import { setCustomFormatValue, saveCustomFormat, fetchCustomFormatSpecifications, cloneCustomFormatSpecification, deleteCustomFormatSpecification } from 'Store/Actions/settingsActions'; import EditCustomFormatModalContent from './EditCustomFormatModalContent'; function createMapStateToProps() { return createSelector( (state) => state.settings.advancedSettings, createProviderSettingsSelector('customFormats'), - (advancedSettings, customFormat) => { + (state) => state.settings.customFormatSpecifications, + (advancedSettings, customFormat, specifications) => { return { advancedSettings, - ...customFormat + ...customFormat, + specificationsPopulated: specifications.isPopulated, + specifications: specifications.items }; } ); @@ -21,7 +24,10 @@ function createMapStateToProps() { const mapDispatchToProps = { setCustomFormatValue, - saveCustomFormat + saveCustomFormat, + fetchCustomFormatSpecifications, + cloneCustomFormatSpecification, + deleteCustomFormatSpecification }; class EditCustomFormatModalContentConnector extends Component { @@ -29,6 +35,14 @@ class EditCustomFormatModalContentConnector extends Component { // // Lifecycle + componentDidMount() { + const { + id, + tagsFromId + } = this.props; + this.props.fetchCustomFormatSpecifications({ id: tagsFromId || id }); + } + componentDidUpdate(prevProps, prevState) { if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { this.props.onModalClose(); @@ -46,6 +60,14 @@ class EditCustomFormatModalContentConnector extends Component { this.props.saveCustomFormat({ id: this.props.id }); } + onCloneSpecificationPress = (id) => { + this.props.cloneCustomFormatSpecification({ id }); + } + + onConfirmDeleteSpecification = (id) => { + this.props.deleteCustomFormatSpecification({ id }); + } + // // Render @@ -55,6 +77,8 @@ class EditCustomFormatModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onInputChange={this.onInputChange} + onCloneSpecificationPress={this.onCloneSpecificationPress} + onConfirmDeleteSpecification={this.onConfirmDeleteSpecification} /> ); } @@ -62,12 +86,16 @@ class EditCustomFormatModalContentConnector extends Component { EditCustomFormatModalContentConnector.propTypes = { id: PropTypes.number, + tagsFromId: PropTypes.number, isFetching: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, item: PropTypes.object.isRequired, setCustomFormatValue: PropTypes.func.isRequired, saveCustomFormat: PropTypes.func.isRequired, + fetchCustomFormatSpecifications: PropTypes.func.isRequired, + cloneCustomFormatSpecification: PropTypes.func.isRequired, + deleteCustomFormatSpecification: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationItem.css b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationItem.css new file mode 100644 index 000000000..eabcae750 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationItem.css @@ -0,0 +1,44 @@ +.specification { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationItem.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationItem.js new file mode 100644 index 000000000..0e5c351bc --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem'; +import styles from './AddSpecificationItem.css'; + +class AddSpecificationItem extends Component { + + // + // Listeners + + onSpecificationSelect = () => { + const { + implementation + } = this.props; + + this.props.onSpecificationSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onSpecificationSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset, index) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddSpecificationItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onSpecificationSelect: PropTypes.func.isRequired +}; + +export default AddSpecificationItem; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModal.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModal.js new file mode 100644 index 000000000..19d8a4335 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddSpecificationModalContentConnector from './AddSpecificationModalContentConnector'; + +function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddSpecificationModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContent.css new file mode 100644 index 000000000..d51349ea9 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContent.css @@ -0,0 +1,5 @@ +.specifications { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContent.js new file mode 100644 index 000000000..fa526a451 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContent.js @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddSpecificationItem from './AddSpecificationItem'; +import styles from './AddSpecificationModalContent.css'; + +class AddSpecificationModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema, + onSpecificationSelect, + onModalClose + } = this.props; + + return ( + + + Add Condition + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new condition, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Radarr supports custom conditions against the following release properties
+
Visit github for more details
+
+ +
+ { + schema.map((specification) => { + return ( + + ); + }) + } +
+ +
+ } +
+ + + +
+ ); + } +} + +AddSpecificationModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + schema: PropTypes.arrayOf(PropTypes.object).isRequired, + onSpecificationSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddSpecificationModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContentConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContentConnector.js new file mode 100644 index 000000000..79c992c9a --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationModalContentConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchCustomFormatSpecificationSchema, selectCustomFormatSpecificationSchema } from 'Store/Actions/settingsActions'; +import AddSpecificationModalContent from './AddSpecificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.customFormatSpecifications, + (specifications) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = specifications; + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + }; + } + ); +} + +const mapDispatchToProps = { + fetchCustomFormatSpecificationSchema, + selectCustomFormatSpecificationSchema +}; + +class AddSpecificationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchCustomFormatSpecificationSchema(); + } + + // + // Listeners + + onSpecificationSelect = ({ implementation, name }) => { + this.props.selectCustomFormatSpecificationSchema({ implementation, presetName: name }); + this.props.onModalClose({ specificationSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddSpecificationModalContentConnector.propTypes = { + fetchCustomFormatSpecificationSchema: PropTypes.func.isRequired, + selectCustomFormatSpecificationSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddSpecificationModalContentConnector); diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationPresetMenuItem.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationPresetMenuItem.js new file mode 100644 index 000000000..e007d9e21 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/AddSpecificationPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddSpecificationPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddSpecificationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddSpecificationPresetMenuItem; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModal.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModal.js new file mode 100644 index 000000000..5b312ecfc --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditSpecificationModalContentConnector from './EditSpecificationModalContentConnector'; + +function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSpecificationModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalConnector.js new file mode 100644 index 000000000..996c49c97 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalConnector.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditSpecificationModal from './EditSpecificationModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.customFormatSpecifications'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + } + }; +} + +class EditSpecificationModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditSpecificationModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditSpecificationModalConnector); diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js new file mode 100644 index 000000000..95c6ec54f --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js @@ -0,0 +1,154 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Link from 'Components/Link/Link'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditSpecificationModalContent.css'; + +function EditSpecificationModalContent(props) { + const { + advancedSettings, + item, + onInputChange, + onFieldChange, + onCancelPress, + onSavePress, + onDeleteSpecificationPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + negate, + required, + fields + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Condition - ${implementationName}`} + + + +
+ { + fields && fields.some((x) => x.label === 'Regular Expression') && + +
This condition matches using Regular Expressions. See here for details. Note that the characters {'\\^$.|?*+()[{'} have special meanings and need escaping with a \
+
Regular expressions can be tested here.
+
+ } + + + + Name + + + + + + { + fields && fields.map((field) => { + return ( + + ); + }) + } + + + + Negate + + + + + + + + Required + + + + + +
+ + { + id && + + } + + + + + Save + + +
+ ); +} + +EditSpecificationModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onCancelPress: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteSpecificationPress: PropTypes.func +}; + +export default EditSpecificationModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContentConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContentConnector.js new file mode 100644 index 000000000..3aea8fc01 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContentConnector.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setCustomFormatSpecificationValue, setCustomFormatSpecificationFieldValue, saveCustomFormatSpecification, clearCustomFormatSpecificationPending } from 'Store/Actions/settingsActions'; +import EditSpecificationModalContent from './EditSpecificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('customFormatSpecifications'), + (advancedSettings, specification) => { + return { + advancedSettings, + ...specification + }; + } + ); +} + +const mapDispatchToProps = { + setCustomFormatSpecificationValue, + setCustomFormatSpecificationFieldValue, + saveCustomFormatSpecification, + clearCustomFormatSpecificationPending +}; + +class EditSpecificationModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setCustomFormatSpecificationValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setCustomFormatSpecificationFieldValue({ name, value }); + } + + onCancelPress = () => { + this.props.clearCustomFormatSpecificationPending(); + this.props.onModalClose(); + } + + onSavePress = () => { + this.props.saveCustomFormatSpecification({ id: this.props.id }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditSpecificationModalContentConnector.propTypes = { + id: PropTypes.number, + item: PropTypes.object.isRequired, + setCustomFormatSpecificationValue: PropTypes.func.isRequired, + setCustomFormatSpecificationFieldValue: PropTypes.func.isRequired, + clearCustomFormatSpecificationPending: PropTypes.func.isRequired, + saveCustomFormatSpecification: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditSpecificationModalContentConnector); diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.css b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.css new file mode 100644 index 000000000..f05c942b0 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.css @@ -0,0 +1,38 @@ +.customFormat { + 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; +} + +.labels { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.js new file mode 100644 index 000000000..55dbec520 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/Specification.js @@ -0,0 +1,145 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditSpecificationModalConnector from './EditSpecificationModal'; +import styles from './Specification.css'; + +class Specification extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditSpecificationModalOpen: false, + isDeleteSpecificationModalOpen: false + }; + } + + // + // Listeners + + onEditSpecificationPress = () => { + this.setState({ isEditSpecificationModalOpen: true }); + } + + onEditSpecificationModalClose = () => { + this.setState({ isEditSpecificationModalOpen: false }); + } + + onDeleteSpecificationPress = () => { + this.setState({ + isEditSpecificationModalOpen: false, + isDeleteSpecificationModalOpen: true + }); + } + + onDeleteSpecificationModalClose = () => { + this.setState({ isDeleteSpecificationModalOpen: false }); + } + + onCloneSpecificationPress = () => { + this.props.onCloneSpecificationPress(this.props.id); + } + + onConfirmDeleteSpecification = () => { + this.props.onConfirmDeleteSpecification(this.props.id); + } + + // + // Lifecycle + + render() { + const { + id, + implementationName, + name, + required, + negate + } = this.props; + + return ( + +
+
+ {name} +
+ + +
+ +
+ + + { + negate && + + } + + { + required && + + } +
+ + + + +
+ Are you sure you want to delete format tag '{name}'? +
+
+ } + confirmLabel="Delete" + onConfirm={this.onConfirmDeleteSpecification} + onCancel={this.onDeleteSpecificationModalClose} + /> + + ); + } +} + +Specification.propTypes = { + id: PropTypes.number.isRequired, + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + negate: PropTypes.bool.isRequired, + required: PropTypes.bool.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteSpecification: PropTypes.func.isRequired, + onCloneSpecificationPress: PropTypes.func.isRequired +}; + +export default Specification; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js index e9cf9f83d..411df21d9 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js @@ -79,8 +79,8 @@ function createFormatsSelector() { }); } else { result.push({ - key: format.id, - value: format.name + key: format, + value: name }); } } @@ -201,7 +201,7 @@ class EditQualityProfileModalContentConnector extends Component { return false; } - return i.id === cutoff || (i.format && i.format.id === cutoff); + return i.id === cutoff || (i.format === cutoff); }); // If the cutoff isn't allowed anymore or there isn't a cutoff set one @@ -210,7 +210,7 @@ class EditQualityProfileModalContentConnector extends Component { let cutoffId = null; if (firstAllowed) { - cutoffId = firstAllowed.format ? firstAllowed.format.id : firstAllowed.id; + cutoffId = firstAllowed.format; } this.props.setQualityProfileValue({ name: 'formatCutoff', value: cutoffId }); @@ -241,11 +241,7 @@ class EditQualityProfileModalContentConnector extends Component { onFormatCutoffChange = ({ name, value }) => { const id = parseInt(value); - const item = _.find(this.props.item.formatItems.value, (i) => { - return i.format.id === id; - }); - - const cutoffId = item.format.id; + const cutoffId = _.find(this.props.item.formatItems.value, (i) => i.format === id).format; this.props.setQualityProfileValue({ name, value: cutoffId }); } @@ -281,7 +277,7 @@ class EditQualityProfileModalContentConnector extends Component { onQualityProfileFormatItemAllowedChange = (id, allowed) => { const qualityProfile = _.cloneDeep(this.props.item); const formatItems = qualityProfile.formatItems.value; - const item = _.find(qualityProfile.formatItems.value, (i) => i.format && i.format.id === id); + const item = _.find(qualityProfile.formatItems.value, (i) => i.format === id); item.allowed = allowed; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js index e6e94abbe..a75073fce 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileFormatItems.js @@ -62,12 +62,12 @@ class QualityProfileFormatItems extends Component {
{ - profileFormatItems.map(({ allowed, format }, index) => { + profileFormatItems.map(({ allowed, format, name }, index) => { return ( { + return { + section, + ...payload + }; +}); + +export const setCustomFormatSpecificationFieldValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneCustomFormatSpecification = createAction(CLONE_CUSTOM_FORMAT_SPECIFICATION); + +export const clearCustomFormatSpecification = createAction(CLEAR_CUSTOM_FORMAT_SPECIFICATIONS); + +export const clearCustomFormatSpecificationPending = createThunk(CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING); + +// +// Details + +export default { + + // + // State + + defaultState: { + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/customformat/schema'), + + [FETCH_CUSTOM_FORMAT_SPECIFICATIONS]: (getState, payload, dispatch) => { + let tags = []; + if (payload.id) { + const cfState = getSectionState(getState(), 'settings.customFormats', true); + const cf = cfState.items[cfState.itemMap[payload.id]]; + tags = cf.specifications.map((tag, i) => { + return { + id: i + 1, + ...tag + }; + }); + } + + dispatch(batchActions([ + update({ section, data: tags }), + set({ + section, + isPopulated: true + }) + ])); + }, + + [SAVE_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => { + const { + id, + ...otherPayload + } = payload; + + const saveData = getProviderState({ id, ...otherPayload }, getState, section, false); + + // we have to set id since not actually posting to server yet + if (!saveData.id) { + saveData.id = getNextId(getState().settings.customFormatSpecifications.items); + } + + dispatch(batchActions([ + updateItem({ section, ...saveData }), + set({ + section, + pendingChanges: {} + }) + ])); + }, + + [DELETE_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => { + const id = payload.id; + return dispatch(removeItem({ section, id })); + }, + + [CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING]: (getState, payload, dispatch) => { + return dispatch(set({ + section, + pendingChanges: {} + })); + } + }, + + // + // Reducers + + reducers: { + [SET_CUSTOM_FORMAT_SPECIFICATION_VALUE]: createSetSettingValueReducer(section), + [SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + return selectedSchema; + }); + }, + + [CLONE_CUSTOM_FORMAT_SPECIFICATION]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const items = newState.items; + const item = items.find((i) => i.id === id); + const newId = getNextId(newState.items); + const newItem = { + ...item, + id: newId, + name: `${item.name} - Copy` + }; + newState.items = [...items, newItem]; + newState.itemMap[newId] = newState.items.length - 1; + + return updateSectionState(state, section, newState); + }, + + [CLEAR_CUSTOM_FORMAT_SPECIFICATIONS]: createClearReducer(section, { + isPopulated: false, + error: null, + items: [] + }) + } +}; diff --git a/frontend/src/Store/Actions/Settings/customFormats.js b/frontend/src/Store/Actions/Settings/customFormats.js index b50fb38b2..54c2544bd 100644 --- a/frontend/src/Store/Actions/Settings/customFormats.js +++ b/frontend/src/Store/Actions/Settings/customFormats.js @@ -4,9 +4,9 @@ import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import { set } from '../baseActions'; // // Variables @@ -17,7 +17,6 @@ const section = 'settings.customFormats'; // Actions Types export const FETCH_CUSTOM_FORMATS = 'settings/customFormats/fetchCustomFormats'; -export const FETCH_CUSTOM_FORMAT_SCHEMA = 'settings/customFormats/fetchCustomFormatSchema'; export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat'; export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat'; export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue'; @@ -27,7 +26,6 @@ export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat'; // Action Creators export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS); -export const fetchCustomFormatSchema = createThunk(FETCH_CUSTOM_FORMAT_SCHEMA); export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT); export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT); @@ -49,15 +47,13 @@ export default { // State defaultState: { + isSchemaFetching: false, + isSchemaPopulated: false, isFetching: false, isPopulated: false, error: null, isDeleting: false, deleteError: null, - isSchemaFetching: false, - isSchemaPopulated: false, - schemaError: null, - schema: {}, isSaving: false, saveError: null, items: [], @@ -69,9 +65,21 @@ export default { actionHandlers: { [FETCH_CUSTOM_FORMATS]: createFetchHandler(section, '/customformat'), - [FETCH_CUSTOM_FORMAT_SCHEMA]: createFetchSchemaHandler(section, '/customformat/schema'), - [SAVE_CUSTOM_FORMAT]: createSaveProviderHandler(section, '/customformat'), - [DELETE_CUSTOM_FORMAT]: createRemoveItemHandler(section, '/customformat') + + [DELETE_CUSTOM_FORMAT]: createRemoveItemHandler(section, '/customformat'), + + [SAVE_CUSTOM_FORMAT]: (getState, payload, dispatch) => { + // move the format tags in as a pending change + const state = getState(); + const pendingChanges = state.settings.customFormats.pendingChanges; + pendingChanges.specifications = state.settings.customFormatSpecifications.items; + dispatch(set({ + section, + pendingChanges + })); + + createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch); + } }, // diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 7cb2a3edd..5fa96d16f 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -1,6 +1,7 @@ import { createAction } from 'redux-actions'; import { handleThunks } from 'Store/thunks'; import createHandleActions from './Creators/createHandleActions'; +import customFormatSpecifications from './Settings/customFormatSpecifications'; import customFormats from './Settings/customFormats'; import delayProfiles from './Settings/delayProfiles'; import downloadClients from './Settings/downloadClients'; @@ -23,6 +24,7 @@ import remotePathMappings from './Settings/remotePathMappings'; import restrictions from './Settings/restrictions'; import ui from './Settings/ui'; +export * from './Settings/customFormatSpecifications.js'; export * from './Settings/customFormats'; export * from './Settings/delayProfiles'; export * from './Settings/downloadClients'; @@ -56,6 +58,7 @@ export const section = 'settings'; export const defaultState = { advancedSettings: false, + customFormatSpecifications: customFormatSpecifications.defaultState, customFormats: customFormats.defaultState, delayProfiles: delayProfiles.defaultState, downloadClients: downloadClients.defaultState, @@ -97,6 +100,7 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); // Action Handlers export const actionHandlers = handleThunks({ + ...customFormatSpecifications.actionHandlers, ...customFormats.actionHandlers, ...delayProfiles.actionHandlers, ...downloadClients.actionHandlers, @@ -129,6 +133,7 @@ export const reducers = createHandleActions({ return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); }, + ...customFormatSpecifications.reducers, ...customFormats.reducers, ...delayProfiles.reducers, ...downloadClients.reducers, diff --git a/frontend/src/Utilities/State/getNextId.js b/frontend/src/Utilities/State/getNextId.js new file mode 100644 index 000000000..204aac95a --- /dev/null +++ b/frontend/src/Utilities/State/getNextId.js @@ -0,0 +1,5 @@ +function getNextId(items) { + return items.reduce((id, x) => Math.max(id, x.id), 1) + 1; +} + +export default getNextId; diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js index 60923a646..4159905b8 100644 --- a/frontend/src/Utilities/State/getProviderState.js +++ b/frontend/src/Utilities/State/getProviderState.js @@ -1,7 +1,7 @@ import _ from 'lodash'; import getSectionState from 'Utilities/State/getSectionState'; -function getProviderState(payload, getState, section) { +function getProviderState(payload, getState, section, keyValueOnly=true) { const { id, ...otherPayload @@ -23,10 +23,17 @@ function getProviderState(payload, getState, section) { field.value; // Only send the name and value to the server - result.push({ - name, - value - }); + if (keyValueOnly) { + result.push({ + name, + value + }); + } else { + result.push({ + ...field, + value + }); + } return result; }, []); diff --git a/src/NzbDrone.Api/CustomFormats/CustomFormatModule.cs b/src/NzbDrone.Api/CustomFormats/CustomFormatModule.cs new file mode 100644 index 000000000..a1e82dd9c --- /dev/null +++ b/src/NzbDrone.Api/CustomFormats/CustomFormatModule.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using NzbDrone.Core.CustomFormats; +using Radarr.Http; + +namespace NzbDrone.Api.CustomFormats +{ + public class CustomFormatModule : RadarrRestModule + { + private readonly ICustomFormatService _formatService; + + public CustomFormatModule(ICustomFormatService formatService) + { + _formatService = formatService; + + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + SharedValidator.RuleFor(c => c.Name) + .Must((v, c) => !_formatService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique."); + SharedValidator.RuleFor(c => c.Specifications).NotEmpty(); + + GetResourceAll = GetAll; + + GetResourceById = GetById; + + UpdateResource = Update; + + CreateResource = Create; + + DeleteResource = DeleteFormat; + + Get("schema", x => GetTemplates()); + } + + private int Create(CustomFormatResource customFormatResource) + { + var model = customFormatResource.ToModel(); + return _formatService.Insert(model).Id; + } + + private void Update(CustomFormatResource resource) + { + var model = resource.ToModel(); + _formatService.Update(model); + } + + private CustomFormatResource GetById(int id) + { + return _formatService.GetById(id).ToResource(); + } + + private List GetAll() + { + return _formatService.All().ToResource(); + } + + private void DeleteFormat(int id) + { + _formatService.Delete(id); + } + + private object GetTemplates() + { + return null; + } + } +} diff --git a/src/NzbDrone.Api/Qualities/CustomFormatResource.cs b/src/NzbDrone.Api/CustomFormats/CustomFormatResource.cs similarity index 79% rename from src/NzbDrone.Api/Qualities/CustomFormatResource.cs rename to src/NzbDrone.Api/CustomFormats/CustomFormatResource.cs index b4c658723..f81bbb318 100644 --- a/src/NzbDrone.Api/Qualities/CustomFormatResource.cs +++ b/src/NzbDrone.Api/CustomFormats/CustomFormatResource.cs @@ -3,12 +3,12 @@ using System.Linq; using NzbDrone.Core.CustomFormats; using Radarr.Http.REST; -namespace NzbDrone.Api.Qualities +namespace NzbDrone.Api.CustomFormats { public class CustomFormatResource : RestResource { public string Name { get; set; } - public List FormatTags { get; set; } + public List Specifications { get; set; } public string Simplicity { get; set; } } @@ -20,23 +20,23 @@ namespace NzbDrone.Api.Qualities { Id = model.Id, Name = model.Name, - FormatTags = model.FormatTags.Select(t => t.Raw.ToUpper()).ToList(), + Specifications = model.Specifications.ToList(), }; } + public static List ToResource(this IEnumerable models) + { + return models.Select(m => m.ToResource()).ToList(); + } + public static CustomFormat ToModel(this CustomFormatResource resource) { return new CustomFormat { Id = resource.Id, Name = resource.Name, - FormatTags = resource.FormatTags.Select(s => new FormatTag(s)).ToList(), + Specifications = resource.Specifications.ToList(), }; } - - public static List ToResource(this IEnumerable models) - { - return models.Select(m => m.ToResource()).ToList(); - } } } diff --git a/src/NzbDrone.Api/Profiles/ProfileResource.cs b/src/NzbDrone.Api/Profiles/ProfileResource.cs index c950083b5..cf0084420 100644 --- a/src/NzbDrone.Api/Profiles/ProfileResource.cs +++ b/src/NzbDrone.Api/Profiles/ProfileResource.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Linq; -using NzbDrone.Api.Qualities; +using NzbDrone.Api.CustomFormats; using NzbDrone.Common.Extensions; using NzbDrone.Core.Languages; using NzbDrone.Core.Profiles; diff --git a/src/NzbDrone.Api/Qualities/CustomFormatModule.cs b/src/NzbDrone.Api/Qualities/CustomFormatModule.cs deleted file mode 100644 index 94d9b6edc..000000000 --- a/src/NzbDrone.Api/Qualities/CustomFormatModule.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation; -using Nancy; -using NzbDrone.Core.CustomFormats; -using NzbDrone.Core.Parser; -using Radarr.Http; - -namespace NzbDrone.Api.Qualities -{ - public class CustomFormatModule : RadarrRestModule - { - private readonly ICustomFormatService _formatService; - private readonly ICustomFormatCalculationService _formatCalculator; - private readonly IParsingService _parsingService; - - public CustomFormatModule(ICustomFormatService formatService, - ICustomFormatCalculationService formatCalculator, - IParsingService parsingService) - { - _formatService = formatService; - _formatCalculator = formatCalculator; - _parsingService = parsingService; - - SharedValidator.RuleFor(c => c.Name).NotEmpty(); - SharedValidator.RuleFor(c => c.Name) - .Must((v, c) => !_formatService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique."); - SharedValidator.RuleFor(c => c.FormatTags).SetValidator(new FormatTagValidator()); - SharedValidator.RuleFor(c => c.FormatTags).Must((v, c) => - { - var allFormats = _formatService.All(); - return !allFormats.Any(f => - { - var allTags = f.FormatTags.Select(t => t.Raw.ToLower()); - var allNewTags = c.Select(t => t.ToLower()); - var enumerable = allTags.ToList(); - var newTags = allNewTags.ToList(); - return enumerable.All(newTags.Contains) && f.Id != v.Id && enumerable.Count() == newTags.Count(); - }); - }) - .WithMessage("Should be unique."); - - GetResourceAll = GetAll; - - GetResourceById = GetById; - - UpdateResource = Update; - - CreateResource = Create; - - DeleteResource = DeleteFormat; - - Get("/test", x => Test()); - - Post("/test", x => TestWithNewModel()); - - Get("schema", x => GetTemplates()); - } - - private int Create(CustomFormatResource customFormatResource) - { - var model = customFormatResource.ToModel(); - return _formatService.Insert(model).Id; - } - - private void Update(CustomFormatResource resource) - { - var model = resource.ToModel(); - _formatService.Update(model); - } - - private CustomFormatResource GetById(int id) - { - return _formatService.GetById(id).ToResource(); - } - - private List GetAll() - { - return _formatService.All().ToResource(); - } - - private void DeleteFormat(int id) - { - _formatService.Delete(id); - } - - private object GetTemplates() - { - return CustomFormatService.Templates.SelectMany(t => - { - return t.Value.Select(m => - { - var r = m.ToResource(); - r.Simplicity = t.Key; - return r; - }); - }); - } - - private CustomFormatTestResource Test() - { - var parsed = _parsingService.ParseMovieInfo((string)Request.Query.title, new List()); - if (parsed == null) - { - return null; - } - - return new CustomFormatTestResource - { - Matches = _formatCalculator.MatchFormatTags(parsed).ToResource(), - MatchedFormats = _formatCalculator.ParseCustomFormat(parsed).ToResource() - }; - } - - private CustomFormatTestResource TestWithNewModel() - { - var queryTitle = (string)Request.Query.title; - - var resource = ReadResourceFromRequest(); - - var model = resource.ToModel(); - model.Name = model.Name += " (New)"; - - var parsed = _parsingService.ParseMovieInfo(queryTitle, new List { model }); - if (parsed == null) - { - return null; - } - - return new CustomFormatTestResource - { - Matches = _formatCalculator.MatchFormatTags(parsed).ToResource(), - MatchedFormats = _formatCalculator.ParseCustomFormat(parsed).ToResource() - }; - } - } -} diff --git a/src/NzbDrone.Api/Qualities/FormatTagMatchResultResource.cs b/src/NzbDrone.Api/Qualities/FormatTagMatchResultResource.cs deleted file mode 100644 index fc8a03945..000000000 --- a/src/NzbDrone.Api/Qualities/FormatTagMatchResultResource.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.CustomFormats; -using Radarr.Http.REST; - -namespace NzbDrone.Api.Qualities -{ - public class FormatTagMatchResultResource : RestResource - { - public CustomFormatResource CustomFormat { get; set; } - public List GroupMatches { get; set; } - } - - public class FormatTagGroupMatchesResource : RestResource - { - public string GroupName { get; set; } - public IDictionary Matches { get; set; } - public bool DidMatch { get; set; } - } - - public class CustomFormatTestResource : RestResource - { - public List Matches { get; set; } - public List MatchedFormats { get; set; } - } - - public static class QualityTagMatchResultResourceMapper - { - public static FormatTagMatchResultResource ToResource(this CustomFormatMatchResult model) - { - if (model == null) - { - return null; - } - - return new FormatTagMatchResultResource - { - CustomFormat = model.CustomFormat.ToResource(), - GroupMatches = model.GroupMatches.ToResource() - }; - } - - public static List ToResource(this IList models) - { - return models.Select(ToResource).ToList(); - } - - public static FormatTagGroupMatchesResource ToResource(this FormatTagMatchesGroup model) - { - return new FormatTagGroupMatchesResource - { - GroupName = model.Type.ToString(), - DidMatch = model.DidMatch, - Matches = model.Matches.SelectDictionary(m => m.Key.Raw, m => m.Value) - }; - } - - public static List ToResource(this IList models) - { - return models.Select(ToResource).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/Qualities/FormatTagValidator.cs b/src/NzbDrone.Api/Qualities/FormatTagValidator.cs deleted file mode 100644 index 7f1eee36a..000000000 --- a/src/NzbDrone.Api/Qualities/FormatTagValidator.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation.Validators; -using NzbDrone.Core.CustomFormats; - -namespace NzbDrone.Api.Qualities -{ - public class FormatTagValidator : PropertyValidator - { - public FormatTagValidator() - : base("{ValidationMessage}") - { - } - - protected override bool IsValid(PropertyValidatorContext context) - { - if (context.PropertyValue == null) - { - context.SetMessage("Format Tags cannot be null!"); - return false; - } - - var tags = (IEnumerable)context.PropertyValue; - - var invalidTags = tags.Where(t => !FormatTag.QualityTagRegex.IsMatch(t)); - - if (invalidTags.Count() == 0) - { - return true; - } - - var formatMessage = - $"Format Tags ({string.Join(", ", invalidTags)}) are in an invalid format! Check the Wiki to learn how they should look."; - context.SetMessage(formatMessage); - return false; - } - } - - public static class PropertyValidatorExtensions - { - public static void SetMessage(this PropertyValidatorContext context, string message, string argument = "ValidationMessage") - { - context.MessageFormatter.AppendArgument(argument, message); - } - } -} diff --git a/src/NzbDrone.Core.Test/CustomFormats/CustomFormatComparerFixture.cs b/src/NzbDrone.Core.Test/CustomFormats/CustomFormatComparerFixture.cs index f994e1a9f..fc96281c3 100644 --- a/src/NzbDrone.Core.Test/CustomFormats/CustomFormatComparerFixture.cs +++ b/src/NzbDrone.Core.Test/CustomFormats/CustomFormatComparerFixture.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Languages; using NzbDrone.Core.Profiles; using NzbDrone.Core.Test.CustomFormats; using NzbDrone.Core.Test.Framework; @@ -25,10 +26,10 @@ namespace NzbDrone.Core.Test.Qualities private void GivenDefaultProfileWithFormats() { - _customFormat1 = new CustomFormat("My Format 1", "L_ENGLISH") { Id = 1 }; - _customFormat2 = new CustomFormat("My Format 2", "L_FRENCH") { Id = 2 }; - _customFormat3 = new CustomFormat("My Format 3", "L_SPANISH") { Id = 3 }; - _customFormat4 = new CustomFormat("My Format 4", "L_ITALIAN") { Id = 4 }; + _customFormat1 = new CustomFormat("My Format 1", new LanguageSpecification { Value = (int)Language.English }) { Id = 1 }; + _customFormat2 = new CustomFormat("My Format 2", new LanguageSpecification { Value = (int)Language.French }) { Id = 2 }; + _customFormat3 = new CustomFormat("My Format 3", new LanguageSpecification { Value = (int)Language.Spanish }) { Id = 3 }; + _customFormat4 = new CustomFormat("My Format 4", new LanguageSpecification { Value = (int)Language.Italian }) { Id = 4 }; CustomFormatsFixture.GivenCustomFormats(CustomFormat.None, _customFormat1, _customFormat2, _customFormat3, _customFormat4); diff --git a/src/NzbDrone.Core.Test/CustomFormats/QualityTagFixture.cs b/src/NzbDrone.Core.Test/CustomFormats/QualityTagFixture.cs deleted file mode 100644 index 368d88b15..000000000 --- a/src/NzbDrone.Core.Test/CustomFormats/QualityTagFixture.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Text.RegularExpressions; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.CustomFormats; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.CustomFormats -{ - [TestFixture] - public class QualityTagFixture : CoreTest - { - [TestCase("R_1080", TagType.Resolution, Resolution.R1080p)] - [TestCase("R_720", TagType.Resolution, Resolution.R720p)] - [TestCase("R_576", TagType.Resolution, Resolution.R576p)] - [TestCase("R_480", TagType.Resolution, Resolution.R480p)] - [TestCase("R_2160", TagType.Resolution, Resolution.R2160p)] - [TestCase("S_BLURAY", TagType.Source, Source.BLURAY)] - [TestCase("s_tv", TagType.Source, Source.TV)] - [TestCase("s_workPRINT", TagType.Source, Source.WORKPRINT)] - [TestCase("s_Dvd", TagType.Source, Source.DVD)] - [TestCase("S_WEBdL", TagType.Source, Source.WEBDL)] - [TestCase("S_CAM", TagType.Source, Source.CAM)] - - // [TestCase("L_English", TagType.Language, Language.English)] - // [TestCase("L_Italian", TagType.Language, Language.Italian)] - // [TestCase("L_iTa", TagType.Language, Language.Italian)] - // [TestCase("L_germaN", TagType.Language, Language.German)] - [TestCase("E_Director", TagType.Edition, "director")] - [TestCase("E_RX_Director('?s)?", TagType.Edition, "director('?s)?", TagModifier.Regex)] - [TestCase("E_RXN_Director('?s)?", TagType.Edition, "director('?s)?", TagModifier.Regex, TagModifier.Not)] - [TestCase("E_RXNRQ_Director('?s)?", TagType.Edition, "director('?s)?", TagModifier.Regex, TagModifier.Not, TagModifier.AbsolutelyRequired)] - [TestCase("C_Surround", TagType.Custom, "surround")] - [TestCase("C_RQ_Surround", TagType.Custom, "surround", TagModifier.AbsolutelyRequired)] - [TestCase("C_RQN_Surround", TagType.Custom, "surround", TagModifier.AbsolutelyRequired, TagModifier.Not)] - [TestCase("C_RQNRX_Surround|(5|7)(\\.1)?", TagType.Custom, "surround|(5|7)(\\.1)?", TagModifier.AbsolutelyRequired, TagModifier.Not, TagModifier.Regex)] - [TestCase("G_10<>20", TagType.Size, new[] { 10737418240L, 21474836480L })] - [TestCase("G_15.55<>20", TagType.Size, new[] { 16696685363L, 21474836480L })] - [TestCase("G_15.55<>25.1908754", TagType.Size, new[] { 16696685363L, 27048496500L })] - [TestCase("R__1080", TagType.Resolution, Resolution.R1080p)] - public void should_parse_tag_from_string(string raw, TagType type, object value, params TagModifier[] modifiers) - { - var parsed = new FormatTag(raw); - TagModifier modifier = 0; - foreach (var m in modifiers) - { - modifier |= m; - } - - parsed.TagType.Should().Be(type); - if (value is long[]) - { - value = (((long[])value)[0], ((long[])value)[1]); - } - - if ((parsed.Value as Regex) != null) - { - (parsed.Value as Regex).ToString().Should().Be(value as string); - } - else - { - parsed.Value.Should().Be(value); - } - - parsed.TagModifier.Should().Be(modifier); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/168_custom_format_rework.cs b/src/NzbDrone.Core.Test/Datastore/Migration/168_custom_format_rework.cs new file mode 100644 index 000000000..6eada43eb --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/168_custom_format_rework.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.Linq; +using Dapper; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class custom_format_rework_parserFixture : CoreTest + { + [TestCase(@"C_RX_(x|h)\.?264", "ReleaseTitleSpecification", false, false, @"(x|h)\.?264")] + [TestCase(@"C_(hello)", "ReleaseTitleSpecification", false, false, @"\(hello\)")] + [TestCase("C_Surround", "ReleaseTitleSpecification", false, false, "surround")] + [TestCase("C_RQ_Surround", "ReleaseTitleSpecification", true, false, "surround")] + [TestCase("C_RQN_Surround", "ReleaseTitleSpecification", true, true, "surround")] + [TestCase("C_RQNRX_Surround|(5|7)(\\.1)?", "ReleaseTitleSpecification", true, true, "surround|(5|7)(\\.1)?")] + [TestCase("R_1080", "ResolutionSpecification", false, false, (int)Resolution.R1080p)] + [TestCase("R__1080", "ResolutionSpecification", false, false, (int)Resolution.R1080p)] + [TestCase("R_720", "ResolutionSpecification", false, false, (int)Resolution.R720p)] + [TestCase("R_576", "ResolutionSpecification", false, false, (int)Resolution.R576p)] + [TestCase("R_480", "ResolutionSpecification", false, false, (int)Resolution.R480p)] + [TestCase("R_2160", "ResolutionSpecification", false, false, (int)Resolution.R2160p)] + [TestCase("S_BLURAY", "SourceSpecification", false, false, (int)Source.BLURAY)] + [TestCase("s_tv", "SourceSpecification", false, false, (int)Source.TV)] + [TestCase("s_workPRINT", "SourceSpecification", false, false, (int)Source.WORKPRINT)] + [TestCase("s_Dvd", "SourceSpecification", false, false, (int)Source.DVD)] + [TestCase("S_WEBdL", "SourceSpecification", false, false, (int)Source.WEBDL)] + [TestCase("S_CAM", "SourceSpecification", false, false, (int)Source.CAM)] + [TestCase("L_English", "LanguageSpecification", false, false, 1)] + [TestCase("L_Italian", "LanguageSpecification", false, false, 5)] + [TestCase("L_iTa", "LanguageSpecification", false, false, 5)] + [TestCase("L_germaN", "LanguageSpecification", false, false, 4)] + [TestCase("E_Director", "EditionSpecification", false, false, "director")] + [TestCase("E_RX_Director('?s)?", "EditionSpecification", false, false, "director(\u0027?s)?")] + [TestCase("E_RXN_Director('?s)?", "EditionSpecification", false, true, "director(\u0027?s)?")] + [TestCase("E_RXNRQ_Director('?s)?", "EditionSpecification", true, true, "director(\u0027?s)?")] + public void should_convert_custom_format(string raw, string specType, bool required, bool negated, object value) + { + var format = Subject.ParseFormatTag(raw); + format.Negate.Should().Be(negated); + format.Required.Should().Be(required); + + format.ToJson().Should().Contain(JsonConvert.ToString(value)); + } + + [TestCase("G_10<>20", "SizeSpecification", 10, 20)] + [TestCase("G_15.55<>20", "SizeSpecification", 15.55, 20)] + [TestCase("G_15.55<>25.1908754", "SizeSpecification", 15.55, 25.1908754)] + public void should_convert_size_cf(string raw, string specType, double min, double max) + { + var format = Subject.ParseFormatTag(raw) as SizeSpecification; + format.Negate.Should().Be(false); + format.Required.Should().Be(false); + format.Min.Should().Be(min); + format.Max.Should().Be(max); + } + } + + [TestFixture] + public class custom_format_reworkFixture : MigrationTest + { + [Test] + public void should_convert_custom_format_row_with_one_spec() + { + var db = WithDapperMigrationTestDb(c => + { + c.Insert.IntoTable("CustomFormats").Row(new + { + Id = 1, + Name = "Test", + FormatTags = new List { @"C_(hello)" }.ToJson() + }); + }); + + var json = db.Query("SELECT Specifications FROM CustomFormats").First(); + + ValidateFormatTag(json, "ReleaseTitleSpecification", false, false); + json.Should().Contain($"\"name\": \"Test\""); + } + + [Test] + public void should_convert_custom_format_row_with_two_specs() + { + var db = WithDapperMigrationTestDb(c => + { + c.Insert.IntoTable("CustomFormats").Row(new + { + Id = 1, + Name = "Test", + FormatTags = new List { @"C_(hello)", "E_Director" }.ToJson() + }); + }); + + var json = db.Query("SELECT Specifications FROM CustomFormats").First(); + + ValidateFormatTag(json, "ReleaseTitleSpecification", false, false); + ValidateFormatTag(json, "EditionSpecification", false, false); + json.Should().Contain($"\"name\": \"Release Title 1\""); + json.Should().Contain($"\"name\": \"Edition 1\""); + } + + private void ValidateFormatTag(string json, string type, bool required, bool negated) + { + json.Should().Contain($"\"type\": \"{type}\""); + json.Should().Contain($"\"required\": {required.ToString().ToLower()}"); + json.Should().Contain($"\"negate\": {negated.ToString().ToLower()}"); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs index 721d7fa91..65482ea22 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs @@ -9,6 +9,7 @@ using NzbDrone.Core.CustomFormats; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Movies; +using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; @@ -71,7 +72,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void GivenCustomFormatHigher() { - _customFormat = new CustomFormat("My Format", "L_ENGLISH") { Id = 1 }; + _customFormat = new CustomFormat("My Format", new ResolutionSpecification { Value = (int)Resolution.R1080p }) { Id = 1 }; CustomFormatsFixture.GivenCustomFormats(_customFormat, CustomFormat.None); } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index c64a2602a..9593f7314 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Indexers; +using NzbDrone.Core.Languages; using NzbDrone.Core.Movies; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; @@ -32,8 +33,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenPreferredDownloadProtocol(DownloadProtocol.Usenet); - _customFormat1 = new CustomFormat("My Format 1", "L_ENGLISH") { Id = 1 }; - _customFormat2 = new CustomFormat("My Format 2", "L_FRENCH") { Id = 2 }; + _customFormat1 = new CustomFormat("My Format 1", new LanguageSpecification { Value = (int)Language.English }) { Id = 1 }; + _customFormat2 = new CustomFormat("My Format 2", new LanguageSpecification { Value = (int)Language.French }) { Id = 2 }; CustomFormatsFixture.GivenCustomFormats(CustomFormat.None, _customFormat1, _customFormat2); } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs index bdacb0fec..c5201b0ad 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using NzbDrone.Core.Configuration; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Parser; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.CustomFormats; @@ -15,8 +16,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class QualityUpgradeSpecificationFixture : CoreTest { - private static CustomFormat _customFormat1 = new CustomFormat("My Format 1", "L_ENGLISH") { Id = 1 }; - private static CustomFormat _customFormat2 = new CustomFormat("My Format 2", "L_FRENCH") { Id = 2 }; + private static CustomFormat _customFormat1 = new CustomFormat("My Format 1", new ResolutionSpecification { Value = (int)Resolution.R1080p }) { Id = 1 }; + private static CustomFormat _customFormat2 = new CustomFormat("My Format 2", new ResolutionSpecification { Value = (int)Resolution.R480p }) { Id = 2 }; public static object[] IsUpgradeTestCases = { diff --git a/src/Radarr.Http/ClientSchema/SelectOption.cs b/src/NzbDrone.Core/Annotations/SelectOption.cs similarity index 76% rename from src/Radarr.Http/ClientSchema/SelectOption.cs rename to src/NzbDrone.Core/Annotations/SelectOption.cs index 94975fa7b..3d9491b60 100644 --- a/src/Radarr.Http/ClientSchema/SelectOption.cs +++ b/src/NzbDrone.Core/Annotations/SelectOption.cs @@ -1,4 +1,4 @@ -namespace Radarr.Http.ClientSchema +namespace NzbDrone.Core.Annotations { public class SelectOption { diff --git a/src/NzbDrone.Core/Annotations/SelectOptionsConverter.cs b/src/NzbDrone.Core/Annotations/SelectOptionsConverter.cs new file mode 100644 index 000000000..4451dc716 --- /dev/null +++ b/src/NzbDrone.Core/Annotations/SelectOptionsConverter.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Annotations +{ + public interface ISelectOptionsConverter + { + List GetSelectOptions(); + } +} diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormat.cs b/src/NzbDrone.Core/CustomFormats/CustomFormat.cs index 68fb488d7..60f5bb7c4 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormat.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormat.cs @@ -11,22 +11,22 @@ namespace NzbDrone.Core.CustomFormats { } - public CustomFormat(string name, params string[] tags) + public CustomFormat(string name, params ICustomFormatSpecification[] specs) { Name = name; - FormatTags = tags.Select(t => new FormatTag(t)).ToList(); + Specifications = specs.ToList(); } public static CustomFormat None => new CustomFormat { Id = 0, Name = "None", - FormatTags = new List() + Specifications = new List() }; public string Name { get; set; } - public List FormatTags { get; set; } + public List Specifications { get; set; } public override string ToString() { diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs index 9bd848d0f..60c3a9518 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs @@ -16,7 +16,6 @@ namespace NzbDrone.Core.CustomFormats List ParseCustomFormat(MovieFile movieFile); List ParseCustomFormat(Blacklist blacklist); List ParseCustomFormat(History.History history); - List MatchFormatTags(ParsedMovieInfo movieInfo); } public class CustomFormatCalculationService : ICustomFormatCalculationService @@ -35,88 +34,54 @@ namespace NzbDrone.Core.CustomFormats } public List ParseCustomFormat(ParsedMovieInfo movieInfo) - { - return MatchFormatTags(movieInfo) - .Where(m => m.GoodMatch) - .Select(r => r.CustomFormat) - .ToList(); - } - - public List ParseCustomFormat(MovieFile movieFile) - { - return MatchFormatTags(movieFile) - .Where(m => m.GoodMatch) - .Select(r => r.CustomFormat) - .ToList(); - } - - public List ParseCustomFormat(Blacklist blacklist) - { - return MatchFormatTags(blacklist) - .Where(m => m.GoodMatch) - .Select(r => r.CustomFormat) - .ToList(); - } - - public List ParseCustomFormat(History.History history) - { - return MatchFormatTags(history) - .Where(m => m.GoodMatch) - .Select(r => r.CustomFormat) - .ToList(); - } - - public List MatchFormatTags(ParsedMovieInfo movieInfo) { var formats = _formatService.All(); - var matches = new List(); + var matches = new List(); foreach (var customFormat in formats) { - var tagTypeMatches = customFormat.FormatTags - .GroupBy(t => t.TagType) - .Select(g => new FormatTagMatchesGroup + var specificationMatches = customFormat.Specifications + .GroupBy(t => t.GetType()) + .Select(g => new SpecificationMatchesGroup { - Type = g.Key, - Matches = g.ToDictionary(t => t, t => t.DoesItMatch(movieInfo)) + Matches = g.ToDictionary(t => t, t => t.IsSatisfiedBy(movieInfo)) }) .ToList(); - matches.Add(new CustomFormatMatchResult + if (specificationMatches.All(x => x.DidMatch)) { - CustomFormat = customFormat, - GroupMatches = tagTypeMatches - }); + matches.Add(customFormat); + } } return matches; } - private List MatchFormatTags(MovieFile file) + public List ParseCustomFormat(MovieFile movieFile) { var info = new ParsedMovieInfo { - MovieTitle = file.Movie.Title, - SimpleReleaseTitle = file.GetSceneOrFileName().SimplifyReleaseTitle(), - Quality = file.Quality, - Languages = file.Languages, - ReleaseGroup = file.ReleaseGroup, - Edition = file.Edition, - Year = file.Movie.Year, - ImdbId = file.Movie.ImdbId, + MovieTitle = movieFile.Movie.Title, + SimpleReleaseTitle = movieFile.GetSceneOrFileName().SimplifyReleaseTitle(), + Quality = movieFile.Quality, + Languages = movieFile.Languages, + ReleaseGroup = movieFile.ReleaseGroup, + Edition = movieFile.Edition, + Year = movieFile.Movie.Year, + ImdbId = movieFile.Movie.ImdbId, ExtraInfo = new Dictionary { - { "IndexerFlags", file.IndexerFlags }, - { "Size", file.Size }, - { "Filename", System.IO.Path.GetFileName(file.RelativePath) } + { "IndexerFlags", movieFile.IndexerFlags }, + { "Size", movieFile.Size }, + { "Filename", System.IO.Path.GetFileName(movieFile.RelativePath) } } }; - return MatchFormatTags(info); + return ParseCustomFormat(info); } - private List MatchFormatTags(Blacklist blacklist) + public List ParseCustomFormat(Blacklist blacklist) { var parsed = _parsingService.ParseMovieInfo(blacklist.SourceTitle, null); @@ -137,10 +102,10 @@ namespace NzbDrone.Core.CustomFormats } }; - return MatchFormatTags(info); + return ParseCustomFormat(info); } - private List MatchFormatTags(History.History history) + public List ParseCustomFormat(History.History history) { var movie = _movieService.GetMovie(history.MovieId); var parsed = _parsingService.ParseMovieInfo(history.SourceTitle, null); @@ -165,7 +130,7 @@ namespace NzbDrone.Core.CustomFormats } }; - return MatchFormatTags(info); + return ParseCustomFormat(info); } } } diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatMatchResult.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatMatchResult.cs deleted file mode 100644 index 74df54381..000000000 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatMatchResult.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace NzbDrone.Core.CustomFormats -{ - public class CustomFormatMatchResult - { - public CustomFormat CustomFormat { get; set; } - - public List GroupMatches { get; set; } - - public bool GoodMatch => GroupMatches.All(g => g.DidMatch); - } -} diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatService.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatService.cs index 7e0df81d6..4ba022e44 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatService.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatService.cs @@ -73,34 +73,5 @@ namespace NzbDrone.Core.CustomFormats _formatRepository.Delete(id); _cache.Clear(); } - - public static Dictionary> Templates => new Dictionary> - { - { - "Easy", new List - { - new CustomFormat("x264", @"C_RX_(x|h)\.?264"), - new CustomFormat("x265", @"C_RX_(((x|h)\.?265)|(HEVC))"), - new CustomFormat("Simple Hardcoded Subs", "C_RX_subs?"), - new CustomFormat("Multi Language", "L_English", "L_French") - } - }, - { - "Intermediate", new List - { - new CustomFormat("Hardcoded Subs", @"C_RX_\b(?(\w+SUBS?)\b)|(?(HC|SUBBED))\b"), - new CustomFormat("Surround", @"C_RX_\b((7|5).1)\b"), - new CustomFormat("Preferred Words", @"C_RX_\b(SPARKS|Framestor)\b"), - new CustomFormat("Scene", @"I_G_Scene"), - new CustomFormat("Internal Releases", @"I_HDB_Internal", @"I_AHD_Internal") - } - }, - { - "Advanced", new List - { - new CustomFormat("Custom") - } - } - }; } } diff --git a/src/NzbDrone.Core/CustomFormats/FormatTag.cs b/src/NzbDrone.Core/CustomFormats/FormatTag.cs deleted file mode 100644 index 82c3c0900..000000000 --- a/src/NzbDrone.Core/CustomFormats/FormatTag.cs +++ /dev/null @@ -1,313 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text.RegularExpressions; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Languages; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.CustomFormats -{ - public class FormatTag - { - public static Regex QualityTagRegex = new Regex(@"^(?R|S|M|E|L|C|I|G)(_((?RX)|(?RQ)|(?N)){0,3})?_(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - public static Regex SizeTagRegex = new Regex(@"(?\d+(\.\d+)?)\s*<>\s*(?\d+(\.\d+)?)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - // This function is needed for json deserialization to work. - public FormatTag() - { - } - - public FormatTag(string raw) - { - Raw = raw; - - var match = QualityTagRegex.Match(raw); - if (!match.Success) - { - throw new ArgumentException("Quality Tag is not in the correct format!"); - } - - ParseFormatTagString(match); - } - - public string Raw { get; set; } - public TagType TagType { get; set; } - public TagModifier TagModifier { get; set; } - public object Value { get; set; } - - public bool DoesItMatch(ParsedMovieInfo movieInfo) - { - var match = DoesItMatchWithoutMods(movieInfo); - if (TagModifier.HasFlag(TagModifier.Not)) - { - match = !match; - } - - return match; - } - - private bool MatchString(string compared) - { - if (compared == null) - { - return false; - } - - if (TagModifier.HasFlag(TagModifier.Regex)) - { - var regexValue = (Regex)Value; - return regexValue.IsMatch(compared); - } - else - { - var stringValue = (string)Value; - return compared.ToLower().Contains(stringValue.Replace(" ", string.Empty).ToLower()); - } - } - - private bool DoesItMatchWithoutMods(ParsedMovieInfo movieInfo) - { - if (movieInfo == null) - { - return false; - } - - var filename = (string)movieInfo?.ExtraInfo?.GetValueOrDefault("Filename"); - - switch (TagType) - { - case TagType.Edition: - return MatchString(movieInfo.Edition); - case TagType.Custom: - return MatchString(movieInfo.SimpleReleaseTitle) || MatchString(filename); - case TagType.Language: - return movieInfo?.Languages?.Contains((Language)Value) ?? false; - case TagType.Resolution: - return (movieInfo?.Quality?.Quality?.Resolution ?? (int)Resolution.Unknown) == (int)(Resolution)Value; - case TagType.Modifier: - return (movieInfo?.Quality?.Quality?.Modifier ?? (int)Modifier.NONE) == (Modifier)Value; - case TagType.Source: - return (movieInfo?.Quality?.Quality?.Source ?? (int)Source.UNKNOWN) == (Source)Value; - case TagType.Size: - var size = (movieInfo?.ExtraInfo?.GetValueOrDefault("Size", 0.0) as long?) ?? 0; - var tuple = Value as (long, long)? ?? (0, 0); - return size > tuple.Item1 && size < tuple.Item2; - case TagType.Indexer: -#if !LIBRARY - var flags = movieInfo?.ExtraInfo?.GetValueOrDefault("IndexerFlags") as IndexerFlags?; - return flags?.HasFlag((IndexerFlags)Value) == true; -#endif - default: - return false; - } - } - - private void ParseTagModifier(Match match) - { - if (match.Groups["m_re"].Success) - { - TagModifier |= TagModifier.AbsolutelyRequired; - } - - if (match.Groups["m_r"].Success) - { - TagModifier |= TagModifier.Regex; - } - - if (match.Groups["m_n"].Success) - { - TagModifier |= TagModifier.Not; - } - } - - private void ParseResolutionType(string value) - { - TagType = TagType.Resolution; - switch (value) - { - case "2160": - Value = Resolution.R2160p; - break; - case "1080": - Value = Resolution.R1080p; - break; - case "720": - Value = Resolution.R720p; - break; - case "576": - Value = Resolution.R576p; - break; - case "480": - Value = Resolution.R480p; - break; - default: - break; - } - } - - private void ParseSourceType(string value) - { - TagType = TagType.Source; - switch (value) - { - case "cam": - Value = Source.CAM; - break; - case "telesync": - Value = Source.TELESYNC; - break; - case "telecine": - Value = Source.TELECINE; - break; - case "workprint": - Value = Source.WORKPRINT; - break; - case "dvd": - Value = Source.DVD; - break; - case "tv": - Value = Source.TV; - break; - case "webdl": - Value = Source.WEBDL; - break; - case "bluray": - Value = Source.BLURAY; - break; - default: - break; - } - } - - private void ParseModifierType(string value) - { - TagType = TagType.Modifier; - switch (value) - { - case "regional": - Value = Modifier.REGIONAL; - break; - case "screener": - Value = Modifier.SCREENER; - break; - case "rawhd": - Value = Modifier.RAWHD; - break; - case "brdisk": - Value = Modifier.BRDISK; - break; - case "remux": - Value = Modifier.REMUX; - break; - default: - break; - } - } - - private void ParseIndexerFlagType(string value) - { - TagType = TagType.Indexer; - var flagValues = Enum.GetValues(typeof(IndexerFlags)); - - foreach (IndexerFlags flagValue in flagValues) - { - var flagString = flagValue.ToString(); - if (flagString.ToLower().Replace("_", string.Empty) != value.ToLower().Replace("_", string.Empty)) - { - continue; - } - - Value = flagValue; - break; - } - } - - private void ParseSizeType(string value) - { - TagType = TagType.Size; - var matches = SizeTagRegex.Match(value); - var min = double.Parse(matches.Groups["min"].Value, CultureInfo.InvariantCulture); - var max = double.Parse(matches.Groups["max"].Value, CultureInfo.InvariantCulture); - Value = (min.Gigabytes(), max.Gigabytes()); - } - - private void ParseString(string value) - { - if (TagModifier.HasFlag(TagModifier.Regex)) - { - Value = new Regex(value, RegexOptions.Compiled | RegexOptions.IgnoreCase); - } - else - { - Value = value; - } - } - - private void ParseFormatTagString(Match match) - { - ParseTagModifier(match); - - var type = match.Groups["type"].Value.ToLower(); - var value = match.Groups["value"].Value.ToLower(); - - switch (type) - { - case "r": - ParseResolutionType(value); - break; - case "s": - ParseSourceType(value); - break; - case "m": - ParseModifierType(value); - break; - case "e": - TagType = TagType.Edition; - ParseString(value); - break; - case "l": - TagType = TagType.Language; - Value = LanguageParser.ParseLanguages(value).First(); - break; - case "i": -#if !LIBRARY - ParseIndexerFlagType(value); -#endif - break; - case "g": - ParseSizeType(value); - break; - case "c": - default: - TagType = TagType.Custom; - ParseString(value); - break; - } - } - } - - public enum TagType - { - Resolution = 1, - Source = 2, - Modifier = 4, - Edition = 8, - Language = 16, - Custom = 32, - Indexer = 64, - Size = 128, - } - - [Flags] - public enum TagModifier - { - Regex = 1, - Not = 2, // Do not match - AbsolutelyRequired = 4 - } -} diff --git a/src/NzbDrone.Core/CustomFormats/FormatTagMatchesGroup.cs b/src/NzbDrone.Core/CustomFormats/FormatTagMatchesGroup.cs deleted file mode 100644 index 98652214c..000000000 --- a/src/NzbDrone.Core/CustomFormats/FormatTagMatchesGroup.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace NzbDrone.Core.CustomFormats -{ - public class FormatTagMatchesGroup - { - public TagType Type { get; set; } - - public Dictionary Matches { get; set; } - - public bool DidMatch => !(Matches.Any(m => m.Key.TagModifier.HasFlag(TagModifier.AbsolutelyRequired) && m.Value == false) || - Matches.All(m => m.Value == false)); - } -} diff --git a/src/NzbDrone.Core/CustomFormats/SpecificationMatchesGroup.cs b/src/NzbDrone.Core/CustomFormats/SpecificationMatchesGroup.cs new file mode 100644 index 000000000..e1389638f --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/SpecificationMatchesGroup.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.CustomFormats +{ + public class SpecificationMatchesGroup + { + public Dictionary Matches { get; set; } + + public bool DidMatch => !(Matches.Any(m => m.Key.Required && m.Value == false) || + Matches.All(m => m.Value == false)); + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/CustomFormatSpecificationBase.cs b/src/NzbDrone.Core/CustomFormats/Specifications/CustomFormatSpecificationBase.cs new file mode 100644 index 000000000..0ecfa514b --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/CustomFormatSpecificationBase.cs @@ -0,0 +1,34 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.CustomFormats +{ + public abstract class CustomFormatSpecificationBase : ICustomFormatSpecification + { + public abstract int Order { get; } + public abstract string ImplementationName { get; } + + public virtual string InfoLink => "https://github.com/Radarr/Radarr/wiki/Custom-Formats-Aphrodite"; + + public string Name { get; set; } + public bool Negate { get; set; } + public bool Required { get; set; } + + public ICustomFormatSpecification Clone() + { + return (ICustomFormatSpecification)MemberwiseClone(); + } + + public bool IsSatisfiedBy(ParsedMovieInfo movieInfo) + { + var match = IsSatisfiedByWithoutNegate(movieInfo); + if (Negate) + { + match = !match; + } + + return match; + } + + protected abstract bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo); + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/EditionSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/EditionSpecification.cs new file mode 100644 index 000000000..e9210d30f --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/EditionSpecification.cs @@ -0,0 +1,16 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.CustomFormats +{ + public class EditionSpecification : RegexSpecificationBase + { + public override int Order => 2; + public override string ImplementationName => "Edition"; + public override string InfoLink => "https://github.com/Radarr/Radarr/wiki/Custom-Formats-Aphrodite#edition"; + + protected override bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo) + { + return MatchString(movieInfo.Edition); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/ICustomFormatSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/ICustomFormatSpecification.cs new file mode 100644 index 000000000..001bc1f7b --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/ICustomFormatSpecification.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.CustomFormats +{ + public interface ICustomFormatSpecification + { + int Order { get; } + string InfoLink { get; } + string ImplementationName { get; } + string Name { get; set; } + bool Negate { get; set; } + bool Required { get; set; } + + ICustomFormatSpecification Clone(); + bool IsSatisfiedBy(ParsedMovieInfo movieInfo); + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs new file mode 100644 index 000000000..29683ebd5 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/IndexerFlagSpecification.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.CustomFormats +{ + public class IndexerFlagSpecification : CustomFormatSpecificationBase + { + public override int Order => 4; + public override string ImplementationName => "Indexer Flag"; + + [FieldDefinition(1, Label = "Flag", Type = FieldType.Select, SelectOptions = typeof(IndexerFlags))] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo) + { + var flags = movieInfo?.ExtraInfo?.GetValueOrDefault("IndexerFlags") as IndexerFlags?; + return flags?.HasFlag((IndexerFlags)Value) == true; + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/LanguageSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/LanguageSpecification.cs new file mode 100644 index 000000000..64cb99e66 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/LanguageSpecification.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.CustomFormats +{ + public class LanguageSpecification : CustomFormatSpecificationBase + { + public override int Order => 3; + public override string ImplementationName => "Language"; + + [FieldDefinition(1, Label = "Language", Type = FieldType.Select, SelectOptions = typeof(LanguageFieldConverter))] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo) + { + return movieInfo?.Languages?.Contains((Language)Value) ?? false; + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/QualityModifierSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/QualityModifierSpecification.cs new file mode 100644 index 000000000..1bad22392 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/QualityModifierSpecification.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.CustomFormats +{ + public class QualityModifierSpecification : CustomFormatSpecificationBase + { + public override int Order => 7; + public override string ImplementationName => "Quality Modifier"; + + [FieldDefinition(1, Label = "Quality Modifier", Type = FieldType.Select, SelectOptions = typeof(Modifier))] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo) + { + return (movieInfo?.Quality?.Quality?.Modifier ?? (int)Modifier.NONE) == (Modifier)Value; + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/RegexSpecificationBase.cs b/src/NzbDrone.Core/CustomFormats/Specifications/RegexSpecificationBase.cs new file mode 100644 index 000000000..030dff60a --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/RegexSpecificationBase.cs @@ -0,0 +1,32 @@ +using System.Text.RegularExpressions; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.CustomFormats +{ + public abstract class RegexSpecificationBase : CustomFormatSpecificationBase + { + protected Regex _regex; + protected string _raw; + + [FieldDefinition(1, Label = "Regular Expression")] + public string Value + { + get => _raw; + set + { + _raw = value; + _regex = new Regex(value, RegexOptions.Compiled | RegexOptions.IgnoreCase); + } + } + + protected bool MatchString(string compared) + { + if (compared == null || _regex == null) + { + return false; + } + + return _regex.IsMatch(compared); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/ReleaseTitleSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/ReleaseTitleSpecification.cs new file mode 100644 index 000000000..e8177a97b --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/ReleaseTitleSpecification.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.CustomFormats +{ + public class ReleaseTitleSpecification : RegexSpecificationBase + { + public override int Order => 1; + public override string ImplementationName => "Release Title"; + public override string InfoLink => "https://github.com/Radarr/Radarr/wiki/Custom-Formats-Aphrodite#release-title"; + + protected override bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo) + { + var filename = (string)movieInfo?.ExtraInfo?.GetValueOrDefault("Filename"); + + return MatchString(movieInfo?.SimpleReleaseTitle) || MatchString(filename); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/ResolutionSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/ResolutionSpecification.cs new file mode 100644 index 000000000..279289651 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/ResolutionSpecification.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.CustomFormats +{ + public class ResolutionSpecification : CustomFormatSpecificationBase + { + public override int Order => 6; + public override string ImplementationName => "Resolution"; + + [FieldDefinition(1, Label = "Resolution", Type = FieldType.Select, SelectOptions = typeof(Resolution))] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo) + { + return (movieInfo?.Quality?.Quality?.Resolution ?? (int)Resolution.Unknown) == Value; + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/SizeSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/SizeSpecification.cs new file mode 100644 index 000000000..acf56f17e --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/SizeSpecification.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.CustomFormats +{ + public class SizeSpecification : CustomFormatSpecificationBase + { + public override int Order => 8; + public override string ImplementationName => "Size"; + + [FieldDefinition(1, Label = "Minimum Size", Unit = "GB", Type = FieldType.Number)] + public double Min { get; set; } + + [FieldDefinition(1, Label = "Maximum Size", Unit = "GB", Type = FieldType.Number)] + public double Max { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo) + { + var size = (movieInfo?.ExtraInfo?.GetValueOrDefault("Size", 0.0) as long?) ?? 0; + + return size > Min.Gigabytes() && size < Max.Gigabytes(); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/SourceSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/SourceSpecification.cs new file mode 100644 index 000000000..a5b8e4c8c --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/SourceSpecification.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.CustomFormats +{ + public class SourceSpecification : CustomFormatSpecificationBase + { + public override int Order => 5; + public override string ImplementationName => "Source"; + + [FieldDefinition(1, Label = "Source", Type = FieldType.Select, SelectOptions = typeof(Source))] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(ParsedMovieInfo movieInfo) + { + return (movieInfo?.Quality?.Quality?.Source ?? (int)Source.UNKNOWN) == (Source)Value; + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Converters/CustomFormatSpecificationConverter.cs b/src/NzbDrone.Core/Datastore/Converters/CustomFormatSpecificationConverter.cs new file mode 100644 index 000000000..81343219e --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/CustomFormatSpecificationConverter.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using NzbDrone.Core.CustomFormats; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class CustomFormatSpecificationListConverter : JsonConverter> + { + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) + { + var wrapped = value.Select(x => new SpecificationWrapper + { + Type = x.GetType().Name, + Body = x + }); + + JsonSerializer.Serialize(writer, wrapped, options); + } + + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ValidateToken(reader, JsonTokenType.StartArray); + + var results = new List(); + + reader.Read(); // Advance to the first object after the StartArray token. This should be either a StartObject token, or the EndArray token. Anything else is invalid. + + while (reader.TokenType == JsonTokenType.StartObject) + { + reader.Read(); // Move to type property name + ValidateToken(reader, JsonTokenType.PropertyName); + + reader.Read(); // Move to type property value + ValidateToken(reader, JsonTokenType.String); + var typename = reader.GetString(); + + reader.Read(); // Move to body property name + ValidateToken(reader, JsonTokenType.PropertyName); + + reader.Read(); // Move to start of object (stored in this property) + ValidateToken(reader, JsonTokenType.StartObject); // Start of formattag + + var type = Type.GetType($"NzbDrone.Core.CustomFormats.{typename}, Radarr.Core", true); + var item = (ICustomFormatSpecification)JsonSerializer.Deserialize(ref reader, type, options); + results.Add(item); + + reader.Read(); // Move past end of body object + reader.Read(); // Move past end of 'wrapper' object + } + + ValidateToken(reader, JsonTokenType.EndArray); + + return results; + } + + // Helper function for validating where you are in the JSON + private void ValidateToken(Utf8JsonReader reader, JsonTokenType tokenType) + { + if (reader.TokenType != tokenType) + { + throw new JsonException($"Invalid token: Was expecting a '{tokenType}' token but received a '{reader.TokenType}' token"); + } + } + + private class SpecificationWrapper + { + public string Type { get; set; } + public object Body { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Converters/QualityTagStringConverter.cs b/src/NzbDrone.Core/Datastore/Converters/QualityTagStringConverter.cs deleted file mode 100644 index 6c4f99bd7..000000000 --- a/src/NzbDrone.Core/Datastore/Converters/QualityTagStringConverter.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Data; -using System.Text.Json; -using System.Text.Json.Serialization; -using Dapper; -using NzbDrone.Core.CustomFormats; - -namespace NzbDrone.Core.Datastore.Converters -{ - public class DapperQualityTagStringConverter : SqlMapper.TypeHandler - { - public override void SetValue(IDbDataParameter parameter, FormatTag value) - { - parameter.Value = value.Raw; - } - - public override FormatTag Parse(object value) - { - if (value == null || value is DBNull) - { - return new FormatTag(""); //Will throw argument exception! - } - - return new FormatTag(Convert.ToString(value)); - } - } - - public class QualityTagStringConverter : JsonConverter - { - public override FormatTag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var item = reader.GetString(); - return new FormatTag(Convert.ToString(item)); - } - - public override void Write(Utf8JsonWriter writer, FormatTag value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.Raw); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/168_custom_format_rework.cs b/src/NzbDrone.Core/Datastore/Migration/168_custom_format_rework.cs new file mode 100644 index 000000000..a91d85202 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/168_custom_format_rework.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using Dapper; +using FluentMigrator; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Datastore.Migration.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(168)] + public class custom_format_rework : NzbDroneMigrationBase + { + private static readonly Regex QualityTagRegex = new Regex(@"^(?R|S|M|E|L|C|I|G)(_((?RX)|(?RQ)|(?N)){0,3})?_(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex SizeTagRegex = new Regex(@"(?\d+(\.\d+)?)\s*<>\s*(?\d+(\.\d+)?)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + protected override void MainDbUpgrade() + { + Alter.Table("CustomFormats").AddColumn("Specifications").AsString().WithDefaultValue("[]"); + + Execute.WithConnection(UpdateCustomFormats); + + Delete.Column("FormatTags").FromTable("CustomFormats"); + } + + private void UpdateCustomFormats(IDbConnection conn, IDbTransaction tran) + { + var existing = conn.Query("SELECT Id, Name, FormatTags FROM CustomFormats"); + + var updated = new List(); + + foreach (var row in existing) + { + var specs = row.FormatTags.Select(ParseFormatTag).ToList(); + + // Use format name for spec if only one spec, otherwise use spec type and a digit + if (specs.Count == 1) + { + specs[0].Name = row.Name; + } + else + { + var groups = specs.GroupBy(x => x.ImplementationName); + foreach (var group in groups) + { + var i = 1; + foreach (var spec in group) + { + spec.Name = $"{spec.ImplementationName} {i}"; + i++; + } + } + } + + updated.Add(new Specification168 + { + Id = row.Id, + Specifications = specs + }); + } + + var updateSql = "UPDATE CustomFormats SET Specifications = @Specifications WHERE Id = @Id"; + conn.Execute(updateSql, updated, transaction: tran); + } + + public ICustomFormatSpecification ParseFormatTag(string raw) + { + var match = QualityTagRegex.Match(raw); + if (!match.Success) + { + throw new ArgumentException("Quality Tag is not in the correct format!"); + } + + var result = InitializeSpecification(match); + result.Negate = match.Groups["m_n"].Success; + result.Required = match.Groups["m_re"].Success; + + return result; + } + + private ICustomFormatSpecification InitializeSpecification(Match match) + { + var type = match.Groups["type"].Value.ToLower(); + var value = match.Groups["value"].Value.ToLower(); + var isRegex = match.Groups["m_r"].Success; + + switch (type) + { + case "r": + return new ResolutionSpecification { Value = (int)ParseResolution(value) }; + case "s": + return new SourceSpecification { Value = (int)ParseSource(value) }; + case "m": + return new QualityModifierSpecification { Value = (int)ParseModifier(value) }; + case "e": + return new EditionSpecification { Value = ParseString(value, isRegex) }; + case "l": + return new LanguageSpecification { Value = (int)LanguageParser.ParseLanguages(value).First() }; + case "i": + return new IndexerFlagSpecification { Value = (int)ParseIndexerFlag(value) }; + case "g": + var minMax = ParseSize(value); + return new SizeSpecification { Min = minMax.Item1, Max = minMax.Item2 }; + case "c": + default: + return new ReleaseTitleSpecification { Value = ParseString(value, isRegex) }; + } + } + + private Resolution ParseResolution(string value) + { + switch (value) + { + case "2160": + return Resolution.R2160p; + case "1080": + return Resolution.R1080p; + case "720": + return Resolution.R720p; + case "576": + return Resolution.R576p; + case "480": + return Resolution.R480p; + default: + return Resolution.Unknown; + } + } + + private Source ParseSource(string value) + { + switch (value) + { + case "cam": + return Source.CAM; + case "telesync": + return Source.TELESYNC; + case "telecine": + return Source.TELECINE; + case "workprint": + return Source.WORKPRINT; + case "dvd": + return Source.DVD; + case "tv": + return Source.TV; + case "webdl": + return Source.WEBDL; + case "bluray": + return Source.BLURAY; + default: + return Source.UNKNOWN; + } + } + + private Modifier ParseModifier(string value) + { + switch (value) + { + case "regional": + return Modifier.REGIONAL; + case "screener": + return Modifier.SCREENER; + case "rawhd": + return Modifier.RAWHD; + case "brdisk": + return Modifier.BRDISK; + case "remux": + return Modifier.REMUX; + default: + return Modifier.NONE; + } + } + + private IndexerFlags ParseIndexerFlag(string value) + { + var flagValues = Enum.GetValues(typeof(IndexerFlags)); + + foreach (IndexerFlags flagValue in flagValues) + { + var flagString = flagValue.ToString(); + if (flagString.ToLower().Replace("_", string.Empty) != value.ToLower().Replace("_", string.Empty)) + { + continue; + } + + return flagValue; + } + + return default; + } + + private (double, double) ParseSize(string value) + { + var matches = SizeTagRegex.Match(value); + var min = double.Parse(matches.Groups["min"].Value, CultureInfo.InvariantCulture); + var max = double.Parse(matches.Groups["max"].Value, CultureInfo.InvariantCulture); + return (min, max); + } + + private string ParseString(string value, bool isRegex) + { + return isRegex ? value : Regex.Escape(value); + } + + private class FormatTag167 : ModelBase + { + public string Name { get; set; } + public List FormatTags { get; set; } + } + + private class Specification168 : ModelBase + { + public List Specifications { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 8290918fa..55895c72e 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -149,7 +149,7 @@ namespace NzbDrone.Core.Datastore SqlMapper.AddTypeHandler(new DapperQualityIntConverter()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new QualityIntConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new CustomFormatIntConverter())); - SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new QualityTagStringConverter())); + SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new CustomFormatSpecificationListConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter(new QualityIntConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>()); diff --git a/src/NzbDrone.Core/Languages/LanguageFieldConverter.cs b/src/NzbDrone.Core/Languages/LanguageFieldConverter.cs new file mode 100644 index 000000000..801a251ac --- /dev/null +++ b/src/NzbDrone.Core/Languages/LanguageFieldConverter.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.Languages +{ + public class LanguageFieldConverter : ISelectOptionsConverter + { + public List GetSelectOptions() + { + return Language.All.ConvertAll(v => new SelectOption { Value = v.Id, Name = v.Name }); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/ProfileService.cs b/src/NzbDrone.Core/Profiles/ProfileService.cs index 7d6e6a018..41c126db6 100644 --- a/src/NzbDrone.Core/Profiles/ProfileService.cs +++ b/src/NzbDrone.Core/Profiles/ProfileService.cs @@ -87,9 +87,9 @@ namespace NzbDrone.Core.Profiles var all = All(); foreach (var profile in all) { - profile.FormatItems.Add(new ProfileFormatItem + profile.FormatItems.Insert(0, new ProfileFormatItem { - Allowed = true, + Allowed = false, Format = message.CustomFormat }); diff --git a/src/Radarr.Api.V3/CustomFormats/CustomFormatModule.cs b/src/Radarr.Api.V3/CustomFormats/CustomFormatModule.cs index a20fad06e..485209991 100644 --- a/src/Radarr.Api.V3/CustomFormats/CustomFormatModule.cs +++ b/src/Radarr.Api.V3/CustomFormats/CustomFormatModule.cs @@ -1,9 +1,8 @@ +using System; using System.Collections.Generic; using System.Linq; using FluentValidation; -using Nancy; using NzbDrone.Core.CustomFormats; -using NzbDrone.Core.Parser; using Radarr.Http; namespace Radarr.Api.V3.CustomFormats @@ -11,34 +10,18 @@ namespace Radarr.Api.V3.CustomFormats public class CustomFormatModule : RadarrRestModule { private readonly ICustomFormatService _formatService; - private readonly ICustomFormatCalculationService _formatCalculator; - private readonly IParsingService _parsingService; + private readonly List _specifications; public CustomFormatModule(ICustomFormatService formatService, - ICustomFormatCalculationService formatCalculator, - IParsingService parsingService) + List specifications) { _formatService = formatService; - _formatCalculator = formatCalculator; - _parsingService = parsingService; + _specifications = specifications; SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Name) .Must((v, c) => !_formatService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique."); - SharedValidator.RuleFor(c => c.FormatTags).SetValidator(new FormatTagValidator()); - SharedValidator.RuleFor(c => c.FormatTags).Must((v, c) => - { - var allFormats = _formatService.All(); - return !allFormats.Any(f => - { - var allTags = f.FormatTags.Select(t => t.Raw.ToLower()); - var allNewTags = c.Split(',').Select(t => t.ToLower()); - var enumerable = allTags.ToList(); - var newTags = allNewTags.ToList(); - return enumerable.All(newTags.Contains) && f.Id != v.Id && enumerable.Count == newTags.Count; - }); - }) - .WithMessage("Should be unique."); + SharedValidator.RuleFor(c => c.Specifications).NotEmpty(); GetResourceAll = GetAll; @@ -50,22 +33,18 @@ namespace Radarr.Api.V3.CustomFormats DeleteResource = DeleteFormat; - Get("/test", x => Test()); - - Post("/test", x => TestWithNewModel()); - Get("schema", x => GetTemplates()); } private int Create(CustomFormatResource customFormatResource) { - var model = customFormatResource.ToModel(); + var model = customFormatResource.ToModel(_specifications); return _formatService.Insert(model).Id; } private void Update(CustomFormatResource resource) { - var model = resource.ToModel(); + var model = resource.ToModel(_specifications); _formatService.Update(model); } @@ -86,52 +65,66 @@ namespace Radarr.Api.V3.CustomFormats private object GetTemplates() { - return CustomFormatService.Templates.SelectMany(t => + var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList(); + + var presets = GetPresets(); + + foreach (var item in schema) { - return t.Value.Select(m => - { - var r = m.ToResource(); - r.Simplicity = t.Key; - return r; - }); - }); + item.Presets = presets.Where(x => x.GetType().Name == item.Implementation).Select(x => x.ToSchema()).ToList(); + } + + return schema; } - private CustomFormatTestResource Test() + private IEnumerable GetPresets() { - var parsed = _parsingService.ParseMovieInfo((string)Request.Query.title, new List()); - if (parsed == null) + yield return new ReleaseTitleSpecification { - return null; - } + Name = "x264", + Value = @"(x|h)\.?264" + }; - return new CustomFormatTestResource + yield return new ReleaseTitleSpecification { - Matches = _formatCalculator.MatchFormatTags(parsed).ToResource(), - MatchedFormats = _formatCalculator.ParseCustomFormat(parsed).ToResource() + Name = "x265", + Value = @"(((x|h)\.?265)|(HEVC))" }; - } - private CustomFormatTestResource TestWithNewModel() - { - var queryTitle = (string)Request.Query.title; - - var resource = ReadResourceFromRequest(); + yield return new ReleaseTitleSpecification + { + Name = "Simple Hardcoded Subs", + Value = @"C_RX_subs?" + }; - var model = resource.ToModel(); - model.Name = model.Name += " (New)"; + yield return new ReleaseTitleSpecification + { + Name = "Hardcoded Subs", + Value = @"\b(?(\w+SUBS?)\b)|(?(HC|SUBBED))\b" + }; - var parsed = _parsingService.ParseMovieInfo(queryTitle, new List { model }); - if (parsed == null) + yield return new ReleaseTitleSpecification { - return null; - } + Name = "Surround Sound", + Value = @"DTS.?(HD|ES|X(?!\D))|TRUEHD|ATMOS|DD(\+|P).?([5-9])|EAC3.?([5-9])" + }; - return new CustomFormatTestResource + yield return new ReleaseTitleSpecification { - Matches = _formatCalculator.MatchFormatTags(parsed).ToResource(), - MatchedFormats = _formatCalculator.ParseCustomFormat(parsed).ToResource() + Name = "Preferred Words", + Value = @"\b(SPARKS|Framestor)\b" }; + + var formats = _formatService.All(); + foreach (var format in formats) + { + foreach (var condition in format.Specifications) + { + var preset = condition.Clone(); + preset.Name = $"{format.Name}: {preset.Name}"; + yield return preset; + } + } } } } diff --git a/src/Radarr.Api.V3/CustomFormats/CustomFormatResource.cs b/src/Radarr.Api.V3/CustomFormats/CustomFormatResource.cs index c1d489868..b04aafc55 100644 --- a/src/Radarr.Api.V3/CustomFormats/CustomFormatResource.cs +++ b/src/Radarr.Api.V3/CustomFormats/CustomFormatResource.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using NzbDrone.Core.CustomFormats; +using Radarr.Http.ClientSchema; using Radarr.Http.REST; namespace Radarr.Api.V3.CustomFormats @@ -11,8 +12,7 @@ namespace Radarr.Api.V3.CustomFormats [JsonProperty(DefaultValueHandling = DefaultValueHandling.Include)] public override int Id { get; set; } public string Name { get; set; } - public string FormatTags { get; set; } - public string Simplicity { get; set; } + public List Specifications { get; set; } } public static class CustomFormatResourceMapper @@ -23,7 +23,7 @@ namespace Radarr.Api.V3.CustomFormats { Id = model.Id, Name = model.Name, - FormatTags = string.Join(",", model.FormatTags.Select(t => t.Raw.ToUpper()).ToList()), + Specifications = model.Specifications.Select(x => x.ToSchema()).ToList(), }; } @@ -32,7 +32,7 @@ namespace Radarr.Api.V3.CustomFormats return models.Select(m => m.ToResource()).ToList(); } - public static CustomFormat ToModel(this CustomFormatResource resource) + public static CustomFormat ToModel(this CustomFormatResource resource, List specifications) { if (resource.Id == 0 && resource.Name == "None") { @@ -43,8 +43,18 @@ namespace Radarr.Api.V3.CustomFormats { Id = resource.Id, Name = resource.Name, - FormatTags = resource.FormatTags.Split(',').Select(s => new FormatTag(s)).ToList() + Specifications = resource.Specifications.Select(x => MapSpecification(x, specifications)).ToList() }; } + + private static ICustomFormatSpecification MapSpecification(CustomFormatSpecificationSchema resource, List specifications) + { + var type = specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation).GetType(); + var spec = (ICustomFormatSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type); + spec.Name = resource.Name; + spec.Negate = resource.Negate; + spec.Required = resource.Required; + return spec; + } } } diff --git a/src/Radarr.Api.V3/CustomFormats/CustomFormatSpecificationSchema.cs b/src/Radarr.Api.V3/CustomFormats/CustomFormatSpecificationSchema.cs new file mode 100644 index 000000000..7254babb1 --- /dev/null +++ b/src/Radarr.Api.V3/CustomFormats/CustomFormatSpecificationSchema.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using NzbDrone.Core.CustomFormats; +using Radarr.Http.ClientSchema; +using Radarr.Http.REST; + +namespace Radarr.Api.V3.CustomFormats +{ + public class CustomFormatSpecificationSchema : RestResource + { + public string Name { get; set; } + public string Implementation { get; set; } + public string ImplementationName { get; set; } + public string InfoLink { get; set; } + public bool Negate { get; set; } + public bool Required { get; set; } + public List Fields { get; set; } + public List Presets { get; set; } + } + + public static class CustomFormatSpecificationSchemaMapper + { + public static CustomFormatSpecificationSchema ToSchema(this ICustomFormatSpecification model) + { + return new CustomFormatSpecificationSchema + { + Name = model.Name, + Implementation = model.GetType().Name, + ImplementationName = model.ImplementationName, + InfoLink = model.InfoLink, + Negate = model.Negate, + Required = model.Required, + Fields = SchemaBuilder.ToSchema(model) + }; + } + } +} diff --git a/src/Radarr.Api.V3/CustomFormats/FormatTagMatchResultResource.cs b/src/Radarr.Api.V3/CustomFormats/FormatTagMatchResultResource.cs deleted file mode 100644 index 0f8217b9a..000000000 --- a/src/Radarr.Api.V3/CustomFormats/FormatTagMatchResultResource.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.CustomFormats; -using Radarr.Http.REST; - -namespace Radarr.Api.V3.CustomFormats -{ - public class CustomFormatMatchResultResource : RestResource - { - public CustomFormatResource CustomFormat { get; set; } - public List GroupMatches { get; set; } - } - - public class FormatTagGroupMatchesResource : RestResource - { - public string GroupName { get; set; } - public IDictionary Matches { get; set; } - public bool DidMatch { get; set; } - } - - public class CustomFormatTestResource : RestResource - { - public List Matches { get; set; } - public List MatchedFormats { get; set; } - } - - public static class QualityTagMatchResultResourceMapper - { - public static CustomFormatMatchResultResource ToResource(this CustomFormatMatchResult model) - { - if (model == null) - { - return null; - } - - return new CustomFormatMatchResultResource - { - CustomFormat = model.CustomFormat.ToResource(), - GroupMatches = model.GroupMatches.ToResource() - }; - } - - public static List ToResource(this IList models) - { - return models.Select(ToResource).ToList(); - } - - public static FormatTagGroupMatchesResource ToResource(this FormatTagMatchesGroup model) - { - return new FormatTagGroupMatchesResource - { - GroupName = model.Type.ToString(), - DidMatch = model.DidMatch, - Matches = model.Matches.SelectDictionary(m => m.Key.Raw, m => m.Value) - }; - } - - public static List ToResource(this IList models) - { - return models.Select(ToResource).ToList(); - } - } -} diff --git a/src/Radarr.Api.V3/CustomFormats/FormatTagValidator.cs b/src/Radarr.Api.V3/CustomFormats/FormatTagValidator.cs deleted file mode 100644 index 3595769cc..000000000 --- a/src/Radarr.Api.V3/CustomFormats/FormatTagValidator.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation.Validators; -using NzbDrone.Core.CustomFormats; - -namespace Radarr.Api.V3.CustomFormats -{ - public class FormatTagValidator : PropertyValidator - { - public FormatTagValidator() - : base("{ValidationMessage}") - { - } - - protected override bool IsValid(PropertyValidatorContext context) - { - if (context.PropertyValue == null) - { - context.SetMessage("Format Tags cannot be null!"); - return false; - } - - var tags = (IEnumerable)context.PropertyValue.ToString().Split(','); - - var invalidTags = tags.Where(t => !FormatTag.QualityTagRegex.IsMatch(t)); - - if (!invalidTags.Any()) - { - return true; - } - - var formatMessage = - $"Format Tags ({string.Join(", ", invalidTags)}) are in an invalid format! Check the Wiki to learn how they should look."; - context.SetMessage(formatMessage); - return false; - } - } - - public static class PropertyValidatorExtensions - { - public static void SetMessage(this PropertyValidatorContext context, string message, string argument = "ValidationMessage") - { - context.MessageFormatter.AppendArgument(argument, message); - } - } -} diff --git a/src/Radarr.Api.V3/Profiles/Quality/QualityProfileModule.cs b/src/Radarr.Api.V3/Profiles/Quality/QualityProfileModule.cs index d453342b7..b3b541865 100644 --- a/src/Radarr.Api.V3/Profiles/Quality/QualityProfileModule.cs +++ b/src/Radarr.Api.V3/Profiles/Quality/QualityProfileModule.cs @@ -27,7 +27,7 @@ namespace Radarr.Api.V3.Profiles.Quality { var all = _formatService.All().Select(f => f.Id).ToList(); all.Add(CustomFormat.None.Id); - var ids = items.Select(i => i.Format.Id); + var ids = items.Select(i => i.Format); return all.Except(ids).Empty(); }).WithMessage("All Custom Formats and no extra ones need to be present inside your Profile! Try refreshing your browser."); diff --git a/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs b/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs index 3cba24d82..7a8782923 100644 --- a/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs +++ b/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Languages; using NzbDrone.Core.Profiles; using Radarr.Api.V3.CustomFormats; @@ -34,7 +35,8 @@ namespace Radarr.Api.V3.Profiles.Quality public class ProfileFormatItemResource : RestResource { - public CustomFormatResource Format { get; set; } + public int Format { get; set; } + public string Name { get; set; } public bool Allowed { get; set; } } @@ -82,7 +84,8 @@ namespace Radarr.Api.V3.Profiles.Quality { return new ProfileFormatItemResource { - Format = model.Format.ToResource(), + Format = model.Format.Id, + Name = model.Format.Name, Allowed = model.Allowed }; } @@ -129,7 +132,7 @@ namespace Radarr.Api.V3.Profiles.Quality { return new ProfileFormatItem { - Format = resource.Format.ToModel(), + Format = new CustomFormat { Id = resource.Format }, Allowed = resource.Allowed }; } diff --git a/src/Radarr.Http/ClientSchema/Field.cs b/src/Radarr.Http/ClientSchema/Field.cs index 8675789c9..560910c7e 100644 --- a/src/Radarr.Http/ClientSchema/Field.cs +++ b/src/Radarr.Http/ClientSchema/Field.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using NzbDrone.Core.Annotations; namespace Radarr.Http.ClientSchema { diff --git a/src/Radarr.Http/ClientSchema/SchemaBuilder.cs b/src/Radarr.Http/ClientSchema/SchemaBuilder.cs index bca139c83..a2897b736 100644 --- a/src/Radarr.Http/ClientSchema/SchemaBuilder.cs +++ b/src/Radarr.Http/ClientSchema/SchemaBuilder.cs @@ -143,10 +143,21 @@ namespace Radarr.Http.ClientSchema private static List GetSelectOptions(Type selectOptions) { - var options = from Enum e in Enum.GetValues(selectOptions) - select new SelectOption { Value = Convert.ToInt32(e), Name = e.ToString() }; + if (selectOptions.IsEnum) + { + var options = from Enum e in Enum.GetValues(selectOptions) + select new SelectOption { Value = Convert.ToInt32(e), Name = e.ToString() }; + + return options.OrderBy(o => o.Value).ToList(); + } + + if (typeof(ISelectOptionsConverter).IsAssignableFrom(selectOptions)) + { + var converter = Activator.CreateInstance(selectOptions) as ISelectOptionsConverter; + return converter.GetSelectOptions(); + } - return options.OrderBy(o => o.Value).ToList(); + throw new NotSupportedException(); } private static Func GetValueConverter(Type propertyType)