mirror of https://github.com/Radarr/Radarr
New: Better interface for creating custom formats
This commit is contained in:
parent
a5bac30ef3
commit
241bf85f15
|
@ -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 {
|
|||
|
||||
<div>
|
||||
{
|
||||
split(formatTags).map((item) => {
|
||||
specifications.map((item, index) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={item}
|
||||
kind={kinds.DEFAULT}
|
||||
key={index}
|
||||
kind={item.required ? kinds.DANGER : kinds.DEFAULT}
|
||||
>
|
||||
{item}
|
||||
{item.name}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
|
@ -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
|
||||
|
|
|
@ -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 {
|
|||
>
|
||||
<div className={styles.customFormats}>
|
||||
{
|
||||
items.sort(sortByName).map((item) => {
|
||||
items.map((item) => {
|
||||
return (
|
||||
<CustomFormat
|
||||
key={item.id}
|
||||
|
@ -87,6 +93,7 @@ class CustomFormats extends Component {
|
|||
|
||||
<EditCustomFormatModalConnector
|
||||
isOpen={this.state.isCustomFormatModalOpen}
|
||||
tagsFromId={this.state.tagsFromId}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 &&
|
||||
<Form
|
||||
{...otherProps}
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
Name
|
||||
</FormLabel>
|
||||
!isFetching && !error && specificationsPopulated &&
|
||||
<div>
|
||||
<Form
|
||||
{...otherProps}
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
Name
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="name"
|
||||
{...name}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="name"
|
||||
{...name}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
Format Tags
|
||||
</FormLabel>
|
||||
<FieldSet legend="Conditions">
|
||||
<div className={styles.customFormats}>
|
||||
{
|
||||
specifications.map((tag) => {
|
||||
return (
|
||||
<Specification
|
||||
key={tag.id}
|
||||
{...tag}
|
||||
onCloneSpecificationPress={onCloneSpecificationPress}
|
||||
onConfirmDeleteSpecification={onConfirmDeleteSpecification}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT_TAG}
|
||||
name="formatTags"
|
||||
{...formatTags}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
<Card
|
||||
className={styles.addSpecification}
|
||||
onPress={this.onAddSpecificationPress}
|
||||
>
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.ADD}
|
||||
size={45}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<AddSpecificationModal
|
||||
isOpen={isAddSpecificationModalOpen}
|
||||
onModalClose={this.onAddSpecificationModalClose}
|
||||
/>
|
||||
|
||||
<EditSpecificationModalConnector
|
||||
isOpen={isEditSpecificationModalOpen}
|
||||
onModalClose={this.onEditSpecificationModalClose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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 (
|
||||
<div
|
||||
className={styles.specification}
|
||||
>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={this.onSpecificationSelect}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.name}>
|
||||
{implementationName}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{
|
||||
hasPresets &&
|
||||
<span>
|
||||
<Button
|
||||
size={sizes.SMALL}
|
||||
onPress={this.onSpecificationSelect}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
|
||||
<Menu className={styles.presetsMenu}>
|
||||
<Button
|
||||
className={styles.presetsMenuButton}
|
||||
size={sizes.SMALL}
|
||||
>
|
||||
Presets
|
||||
</Button>
|
||||
|
||||
<MenuContent>
|
||||
{
|
||||
presets.map((preset, index) => {
|
||||
return (
|
||||
<AddSpecificationPresetMenuItem
|
||||
key={index}
|
||||
name={preset.name}
|
||||
implementation={implementation}
|
||||
onPress={onSpecificationSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</span>
|
||||
}
|
||||
|
||||
<Button
|
||||
to={infoLink}
|
||||
size={sizes.SMALL}
|
||||
>
|
||||
More info
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AddSpecificationModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddSpecificationModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddSpecificationModal;
|
|
@ -0,0 +1,5 @@
|
|||
.specifications {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
|
@ -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 (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Add Condition
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
isSchemaFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isSchemaFetching && !!schemaError &&
|
||||
<div>Unable to add a new condition, please try again.</div>
|
||||
}
|
||||
|
||||
{
|
||||
isSchemaPopulated && !schemaError &&
|
||||
<div>
|
||||
|
||||
<Alert kind={kinds.INFO}>
|
||||
<div>Radarr supports custom conditions against the following release properties</div>
|
||||
<div>Visit github for more details</div>
|
||||
</Alert>
|
||||
|
||||
<div className={styles.specifications}>
|
||||
{
|
||||
schema.map((specification) => {
|
||||
return (
|
||||
<AddSpecificationItem
|
||||
key={specification.implementation}
|
||||
{...specification}
|
||||
onSpecificationSelect={onSpecificationSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<AddSpecificationModalContent
|
||||
{...this.props}
|
||||
onSpecificationSelect={this.onSpecificationSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddSpecificationModalContentConnector.propTypes = {
|
||||
fetchCustomFormatSpecificationSchema: PropTypes.func.isRequired,
|
||||
selectCustomFormatSpecificationSchema: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AddSpecificationModalContentConnector);
|
|
@ -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 (
|
||||
<MenuItem
|
||||
{...otherProps}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddSpecificationPresetMenuItem.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
implementation: PropTypes.string.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddSpecificationPresetMenuItem;
|
|
@ -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 (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditSpecificationModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
EditSpecificationModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditSpecificationModal;
|
|
@ -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 (
|
||||
<EditSpecificationModal
|
||||
{...otherProps}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditSpecificationModalConnector.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
dispatchClearPendingChanges: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, createMapDispatchToProps)(EditSpecificationModalConnector);
|
|
@ -0,0 +1,5 @@
|
|||
.deleteButton {
|
||||
composes: button from '~Components/Link/Button.css';
|
||||
|
||||
margin-right: auto;
|
||||
}
|
|
@ -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 (
|
||||
<ModalContent onModalClose={onCancelPress}>
|
||||
<ModalHeader>
|
||||
{`${id ? 'Edit' : 'Add'} Condition - ${implementationName}`}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form
|
||||
{...otherProps}
|
||||
>
|
||||
{
|
||||
fields && fields.some((x) => x.label === 'Regular Expression') &&
|
||||
<Alert kind={kinds.INFO}>
|
||||
<div>This condition matches using Regular Expressions. See <Link to="https://www.regular-expressions.info/tutorial.html">here</Link> for details. Note that the characters <code>{'\\^$.|?*+()[{'}</code> have special meanings and need escaping with a <code>\</code></div>
|
||||
<div>Regular expressions can be tested <Link to="http://regexstorm.net/tester">here</Link>.</div>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
Name
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="name"
|
||||
{...name}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
fields && fields.map((field) => {
|
||||
return (
|
||||
<ProviderFieldFormGroup
|
||||
key={field.name}
|
||||
advancedSettings={advancedSettings}
|
||||
provider="specifications"
|
||||
providerData={item}
|
||||
{...field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
Negate
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="negate"
|
||||
{...negate}
|
||||
helpText={`If checked, the custom format will not apply if this ${implementationName} condition matches.`}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
Required
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="required"
|
||||
{...required}
|
||||
helpText={`This ${implementationName} condition must match for the custom format to apply. Otherwise a single ${implementationName} match is sufficient.`}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{
|
||||
id &&
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteSpecificationPress}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onCancelPress}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={false}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
Save
|
||||
</SpinnerErrorButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<EditSpecificationModalContent
|
||||
{...this.props}
|
||||
onCancelPress={this.onCancelPress}
|
||||
onSavePress={this.onSavePress}
|
||||
onInputChange={this.onInputChange}
|
||||
onFieldChange={this.onFieldChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
|
@ -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;
|
||||
}
|
|
@ -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 (
|
||||
<Card
|
||||
className={styles.customFormat}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditSpecificationPress}
|
||||
>
|
||||
<div className={styles.nameContainer}>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.cloneButton}
|
||||
title="Clone Format Tag"
|
||||
name={icons.CLONE}
|
||||
onPress={this.onCloneSpecificationPress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.labels}>
|
||||
<Label kind={kinds.DEFAULT}>
|
||||
{implementationName}
|
||||
</Label>
|
||||
|
||||
{
|
||||
negate &&
|
||||
<Label kind={kinds.INVERSE}>
|
||||
{'Negated'}
|
||||
</Label>
|
||||
}
|
||||
|
||||
{
|
||||
required &&
|
||||
<Label kind={kinds.DANGER}>
|
||||
{'Required'}
|
||||
</Label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<EditSpecificationModalConnector
|
||||
id={id}
|
||||
isOpen={this.state.isEditSpecificationModalOpen}
|
||||
onModalClose={this.onEditSpecificationModalClose}
|
||||
onDeleteSpecificationPress={this.onDeleteSpecificationPress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isDeleteSpecificationModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title="Delete Custom Format"
|
||||
message={
|
||||
<div>
|
||||
<div>
|
||||
Are you sure you want to delete format tag '{name}'?
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={this.onConfirmDeleteSpecification}
|
||||
onCancel={this.onDeleteSpecificationModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -62,12 +62,12 @@ class QualityProfileFormatItems extends Component {
|
|||
|
||||
<div className={styles.formats}>
|
||||
{
|
||||
profileFormatItems.map(({ allowed, format }, index) => {
|
||||
profileFormatItems.map(({ allowed, format, name }, index) => {
|
||||
return (
|
||||
<QualityProfileFormatItemDragSource
|
||||
key={format.id}
|
||||
formatId={format.id}
|
||||
name={format.name}
|
||||
key={format}
|
||||
formatId={format}
|
||||
name={name}
|
||||
allowed={allowed}
|
||||
sortIndex={index}
|
||||
isDragging={isDragging}
|
||||
|
|
|
@ -0,0 +1,184 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
import getNextId from 'Utilities/State/getNextId';
|
||||
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
|
||||
import getProviderState from 'Utilities/State/getProviderState';
|
||||
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
|
||||
import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
|
||||
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
|
||||
import { set, update, updateItem, removeItem } from '../baseActions';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
const section = 'settings.customFormatSpecifications';
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/fetchCustomFormatSpecifications';
|
||||
export const FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA = 'settings/customFormatSpecifications/fetchCustomFormatSpecificationSchema';
|
||||
export const SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA = 'settings/customFormatSpecifications/selectCustomFormatSpecificationSchema';
|
||||
export const SET_CUSTOM_FORMAT_SPECIFICATION_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationValue';
|
||||
export const SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationFieldValue';
|
||||
export const SAVE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/saveCustomFormatSpecification';
|
||||
export const DELETE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteCustomFormatSpecification';
|
||||
export const CLONE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/cloneCustomFormatSpecification';
|
||||
export const CLEAR_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/clearCustomFormatSpecifications';
|
||||
export const CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING = 'settings/customFormatSpecifications/clearCustomFormatSpecificationPending';
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchCustomFormatSpecifications = createThunk(FETCH_CUSTOM_FORMAT_SPECIFICATIONS);
|
||||
export const fetchCustomFormatSpecificationSchema = createThunk(FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA);
|
||||
export const selectCustomFormatSpecificationSchema = createAction(SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA);
|
||||
|
||||
export const saveCustomFormatSpecification = createThunk(SAVE_CUSTOM_FORMAT_SPECIFICATION);
|
||||
export const deleteCustomFormatSpecification = createThunk(DELETE_CUSTOM_FORMAT_SPECIFICATION);
|
||||
|
||||
export const setCustomFormatSpecificationValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_VALUE, (payload) => {
|
||||
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: []
|
||||
})
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
//
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
function getNextId(items) {
|
||||
return items.reduce((id, x) => Math.max(id, x.id), 1) + 1;
|
||||
}
|
||||
|
||||
export default getNextId;
|
|
@ -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;
|
||||
}, []);
|
||||
|
|
|
@ -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<CustomFormatResource>
|
||||
{
|
||||
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<CustomFormatResource> GetAll()
|
||||
{
|
||||
return _formatService.All().ToResource();
|
||||
}
|
||||
|
||||
private void DeleteFormat(int id)
|
||||
{
|
||||
_formatService.Delete(id);
|
||||
}
|
||||
|
||||
private object GetTemplates()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<string> FormatTags { get; set; }
|
||||
public List<ICustomFormatSpecification> 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<CustomFormatResource> ToResource(this IEnumerable<CustomFormat> 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<CustomFormatResource> ToResource(this IEnumerable<CustomFormat> models)
|
||||
{
|
||||
return models.Select(m => m.ToResource()).ToList();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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<CustomFormatResource>
|
||||
{
|
||||
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<CustomFormatResource> 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<object>());
|
||||
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<object> { model });
|
||||
if (parsed == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CustomFormatTestResource
|
||||
{
|
||||
Matches = _formatCalculator.MatchFormatTags(parsed).ToResource(),
|
||||
MatchedFormats = _formatCalculator.ParseCustomFormat(parsed).ToResource()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<FormatTagGroupMatchesResource> GroupMatches { get; set; }
|
||||
}
|
||||
|
||||
public class FormatTagGroupMatchesResource : RestResource
|
||||
{
|
||||
public string GroupName { get; set; }
|
||||
public IDictionary<string, bool> Matches { get; set; }
|
||||
public bool DidMatch { get; set; }
|
||||
}
|
||||
|
||||
public class CustomFormatTestResource : RestResource
|
||||
{
|
||||
public List<FormatTagMatchResultResource> Matches { get; set; }
|
||||
public List<CustomFormatResource> 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<FormatTagMatchResultResource> ToResource(this IList<CustomFormatMatchResult> 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<FormatTagGroupMatchesResource> ToResource(this IList<FormatTagMatchesGroup> models)
|
||||
{
|
||||
return models.Select(ToResource).ToList();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<string>)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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<custom_format_rework>
|
||||
{
|
||||
[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<custom_format_rework>
|
||||
{
|
||||
[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<string> { @"C_(hello)" }.ToJson()
|
||||
});
|
||||
});
|
||||
|
||||
var json = db.Query<string>("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<string> { @"C_(hello)", "E_Director" }.ToJson()
|
||||
});
|
||||
});
|
||||
|
||||
var json = db.Query<string>("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()}");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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<UpgradableSpecification>
|
||||
{
|
||||
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 =
|
||||
{
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
namespace Radarr.Http.ClientSchema
|
||||
namespace NzbDrone.Core.Annotations
|
||||
{
|
||||
public class SelectOption
|
||||
{
|
|
@ -0,0 +1,9 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.Annotations
|
||||
{
|
||||
public interface ISelectOptionsConverter
|
||||
{
|
||||
List<SelectOption> GetSelectOptions();
|
||||
}
|
||||
}
|
|
@ -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<FormatTag>()
|
||||
Specifications = new List<ICustomFormatSpecification>()
|
||||
};
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public List<FormatTag> FormatTags { get; set; }
|
||||
public List<ICustomFormatSpecification> Specifications { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
|
|
|
@ -16,7 +16,6 @@ namespace NzbDrone.Core.CustomFormats
|
|||
List<CustomFormat> ParseCustomFormat(MovieFile movieFile);
|
||||
List<CustomFormat> ParseCustomFormat(Blacklist blacklist);
|
||||
List<CustomFormat> ParseCustomFormat(History.History history);
|
||||
List<CustomFormatMatchResult> MatchFormatTags(ParsedMovieInfo movieInfo);
|
||||
}
|
||||
|
||||
public class CustomFormatCalculationService : ICustomFormatCalculationService
|
||||
|
@ -35,88 +34,54 @@ namespace NzbDrone.Core.CustomFormats
|
|||
}
|
||||
|
||||
public List<CustomFormat> ParseCustomFormat(ParsedMovieInfo movieInfo)
|
||||
{
|
||||
return MatchFormatTags(movieInfo)
|
||||
.Where(m => m.GoodMatch)
|
||||
.Select(r => r.CustomFormat)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<CustomFormat> ParseCustomFormat(MovieFile movieFile)
|
||||
{
|
||||
return MatchFormatTags(movieFile)
|
||||
.Where(m => m.GoodMatch)
|
||||
.Select(r => r.CustomFormat)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<CustomFormat> ParseCustomFormat(Blacklist blacklist)
|
||||
{
|
||||
return MatchFormatTags(blacklist)
|
||||
.Where(m => m.GoodMatch)
|
||||
.Select(r => r.CustomFormat)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<CustomFormat> ParseCustomFormat(History.History history)
|
||||
{
|
||||
return MatchFormatTags(history)
|
||||
.Where(m => m.GoodMatch)
|
||||
.Select(r => r.CustomFormat)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<CustomFormatMatchResult> MatchFormatTags(ParsedMovieInfo movieInfo)
|
||||
{
|
||||
var formats = _formatService.All();
|
||||
|
||||
var matches = new List<CustomFormatMatchResult>();
|
||||
var matches = new List<CustomFormat>();
|
||||
|
||||
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<CustomFormatMatchResult> MatchFormatTags(MovieFile file)
|
||||
public List<CustomFormat> 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<string, object>
|
||||
{
|
||||
{ "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<CustomFormatMatchResult> MatchFormatTags(Blacklist blacklist)
|
||||
public List<CustomFormat> 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<CustomFormatMatchResult> MatchFormatTags(History.History history)
|
||||
public List<CustomFormat> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FormatTagMatchesGroup> GroupMatches { get; set; }
|
||||
|
||||
public bool GoodMatch => GroupMatches.All(g => g.DidMatch);
|
||||
}
|
||||
}
|
|
@ -73,34 +73,5 @@ namespace NzbDrone.Core.CustomFormats
|
|||
_formatRepository.Delete(id);
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
public static Dictionary<string, List<CustomFormat>> Templates => new Dictionary<string, List<CustomFormat>>
|
||||
{
|
||||
{
|
||||
"Easy", new List<CustomFormat>
|
||||
{
|
||||
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<CustomFormat>
|
||||
{
|
||||
new CustomFormat("Hardcoded Subs", @"C_RX_\b(?<hcsub>(\w+SUBS?)\b)|(?<hc>(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<CustomFormat>
|
||||
{
|
||||
new CustomFormat("Custom")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(@"^(?<type>R|S|M|E|L|C|I|G)(_((?<m_r>RX)|(?<m_re>RQ)|(?<m_n>N)){0,3})?_(?<value>.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public static Regex SizeTagRegex = new Regex(@"(?<min>\d+(\.\d+)?)\s*<>\s*(?<max>\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
|
||||
}
|
||||
}
|
|
@ -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<FormatTag, bool> Matches { get; set; }
|
||||
|
||||
public bool DidMatch => !(Matches.Any(m => m.Key.TagModifier.HasFlag(TagModifier.AbsolutelyRequired) && m.Value == false) ||
|
||||
Matches.All(m => m.Value == false));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace NzbDrone.Core.CustomFormats
|
||||
{
|
||||
public class SpecificationMatchesGroup
|
||||
{
|
||||
public Dictionary<ICustomFormatSpecification, bool> Matches { get; set; }
|
||||
|
||||
public bool DidMatch => !(Matches.Any(m => m.Key.Required && m.Value == false) ||
|
||||
Matches.All(m => m.Value == false));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<List<ICustomFormatSpecification>>
|
||||
{
|
||||
public override void Write(Utf8JsonWriter writer, List<ICustomFormatSpecification> value, JsonSerializerOptions options)
|
||||
{
|
||||
var wrapped = value.Select(x => new SpecificationWrapper
|
||||
{
|
||||
Type = x.GetType().Name,
|
||||
Body = x
|
||||
});
|
||||
|
||||
JsonSerializer.Serialize(writer, wrapped, options);
|
||||
}
|
||||
|
||||
public override List<ICustomFormatSpecification> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
ValidateToken(reader, JsonTokenType.StartArray);
|
||||
|
||||
var results = new List<ICustomFormatSpecification>();
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<FormatTag>
|
||||
{
|
||||
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<FormatTag>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(@"^(?<type>R|S|M|E|L|C|I|G)(_((?<m_r>RX)|(?<m_re>RQ)|(?<m_n>N)){0,3})?_(?<value>.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex SizeTagRegex = new Regex(@"(?<min>\d+(\.\d+)?)\s*<>\s*(?<max>\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<FormatTag167>("SELECT Id, Name, FormatTags FROM CustomFormats");
|
||||
|
||||
var updated = new List<Specification168>();
|
||||
|
||||
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<string> FormatTags { get; set; }
|
||||
}
|
||||
|
||||
private class Specification168 : ModelBase
|
||||
{
|
||||
public List<ICustomFormatSpecification> Specifications { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -149,7 +149,7 @@ namespace NzbDrone.Core.Datastore
|
|||
SqlMapper.AddTypeHandler(new DapperQualityIntConverter());
|
||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ProfileQualityItem>>(new QualityIntConverter()));
|
||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ProfileFormatItem>>(new CustomFormatIntConverter()));
|
||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<FormatTag>>(new QualityTagStringConverter()));
|
||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ICustomFormatSpecification>>(new CustomFormatSpecificationListConverter()));
|
||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<QualityModel>(new QualityIntConverter()));
|
||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<Dictionary<string, string>>());
|
||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<IDictionary<string, string>>());
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Annotations;
|
||||
|
||||
namespace NzbDrone.Core.Languages
|
||||
{
|
||||
public class LanguageFieldConverter : ISelectOptionsConverter
|
||||
{
|
||||
public List<SelectOption> GetSelectOptions()
|
||||
{
|
||||
return Language.All.ConvertAll(v => new SelectOption { Value = v.Id, Name = v.Name });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
@ -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<CustomFormatResource>
|
||||
{
|
||||
private readonly ICustomFormatService _formatService;
|
||||
private readonly ICustomFormatCalculationService _formatCalculator;
|
||||
private readonly IParsingService _parsingService;
|
||||
private readonly List<ICustomFormatSpecification> _specifications;
|
||||
|
||||
public CustomFormatModule(ICustomFormatService formatService,
|
||||
ICustomFormatCalculationService formatCalculator,
|
||||
IParsingService parsingService)
|
||||
List<ICustomFormatSpecification> 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<ICustomFormatSpecification> 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(?<hcsub>(\w+SUBS?)\b)|(?<hc>(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<object>());
|
||||
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<object> { model });
|
||||
if (parsed == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CustomFormatTestResource
|
||||
{
|
||||
Matches = _formatCalculator.MatchFormatTags(parsed).ToResource(),
|
||||
MatchedFormats = _formatCalculator.ParseCustomFormat(parsed).ToResource()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<CustomFormatSpecificationSchema> 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<ICustomFormatSpecification> 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<ICustomFormatSpecification> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Field> Fields { get; set; }
|
||||
public List<CustomFormatSpecificationSchema> 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<FormatTagGroupMatchesResource> GroupMatches { get; set; }
|
||||
}
|
||||
|
||||
public class FormatTagGroupMatchesResource : RestResource
|
||||
{
|
||||
public string GroupName { get; set; }
|
||||
public IDictionary<string, bool> Matches { get; set; }
|
||||
public bool DidMatch { get; set; }
|
||||
}
|
||||
|
||||
public class CustomFormatTestResource : RestResource
|
||||
{
|
||||
public List<CustomFormatMatchResultResource> Matches { get; set; }
|
||||
public List<CustomFormatResource> 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<CustomFormatMatchResultResource> ToResource(this IList<CustomFormatMatchResult> 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<FormatTagGroupMatchesResource> ToResource(this IList<FormatTagMatchesGroup> models)
|
||||
{
|
||||
return models.Select(ToResource).ToList();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<string>)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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Annotations;
|
||||
|
||||
namespace Radarr.Http.ClientSchema
|
||||
{
|
||||
|
|
|
@ -143,10 +143,21 @@ namespace Radarr.Http.ClientSchema
|
|||
|
||||
private static List<SelectOption> 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<object, object> GetValueConverter(Type propertyType)
|
||||
|
|
Loading…
Reference in New Issue