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 (
- {item}
+ {item.name}
);
})
@@ -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 &&
-
@@ -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 &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+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 (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+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 &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ 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 (
+
+
+
+
+
+ {implementationName}
+
+
+ {
+ negate &&
+
+ {'Negated'}
+
+ }
+
+ {
+ required &&
+
+ {'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 =>
+ item.Presets = presets.Where(x => x.GetType().Name == item.Implementation).Select(x => x.ToSchema()).ToList();
+ }
+
+ return schema;
+ }
+
+ private IEnumerable GetPresets()
+ {
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "x264",
+ Value = @"(x|h)\.?264"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "x265",
+ Value = @"(((x|h)\.?265)|(HEVC))"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "Simple Hardcoded Subs",
+ Value = @"C_RX_subs?"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "Hardcoded Subs",
+ Value = @"\b(?(\w+SUBS?)\b)|(?(HC|SUBBED))\b"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "Surround Sound",
+ Value = @"DTS.?(HD|ES|X(?!\D))|TRUEHD|ATMOS|DD(\+|P).?([5-9])|EAC3.?([5-9])"
+ };
+
+ yield return new ReleaseTitleSpecification
+ {
+ Name = "Preferred Words",
+ Value = @"\b(SPARKS|Framestor)\b"
+ };
+
+ var formats = _formatService.All();
+ foreach (var format in formats)
+ {
+ foreach (var condition in format.Specifications)
{
- 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;
+ var preset = condition.Clone();
+ preset.Name = $"{format.Name}: {preset.Name}";
+ yield return preset;
+ }
}
-
- 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/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();
+ return options.OrderBy(o => o.Value).ToList();
+ }
+
+ if (typeof(ISelectOptionsConverter).IsAssignableFrom(selectOptions))
+ {
+ var converter = Activator.CreateInstance(selectOptions) as ISelectOptionsConverter;
+ return converter.GetSelectOptions();
+ }
+
+ throw new NotSupportedException();
}
private static Func GetValueConverter(Type propertyType)