diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index f5130d18b..eb0844b8c 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -35,6 +35,8 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.TEXT; case 'oAuth': return inputTypes.OAUTH; + case 'rootFolder': + return inputTypes.ROOT_FOLDER_SELECT; default: return inputTypes.TEXT; } diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css new file mode 100644 index 000000000..b1e2de95b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css @@ -0,0 +1,38 @@ +.autoTagging { + 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; +} + +.formats { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js new file mode 100644 index 000000000..ed72ad99c --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js @@ -0,0 +1,135 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagList from 'Components/TagList'; +import { icons, kinds } from 'Helpers/Props'; +import EditAutoTaggingModal from './EditAutoTaggingModal'; +import styles from './AutoTagging.css'; + +export default function AutoTagging(props) { + const { + id, + name, + tags, + tagList, + specifications, + isDeleting, + onConfirmDeleteAutoTagging, + onCloneAutoTaggingPress + } = props; + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onDeletePress = useCallback(() => { + setIsEditModalOpen(false); + setIsDeleteModalOpen(true); + }, [setIsEditModalOpen, setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onConfirmDelete = useCallback(() => { + onConfirmDeleteAutoTagging(id); + }, [id, onConfirmDeleteAutoTagging]); + + const onClonePress = useCallback(() => { + onCloneAutoTaggingPress(id); + }, [id, onCloneAutoTaggingPress]); + + return ( + +
+
+ {name} +
+ +
+ +
+
+ + + +
+ { + specifications.map((item, index) => { + if (!item) { + return null; + } + + let kind = kinds.DEFAULT; + if (item.required) { + kind = kinds.SUCCESS; + } + if (item.negate) { + kind = kinds.DANGER; + } + + return ( + + ); + }) + } +
+ + + + +
+ ); +} + +AutoTagging.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + specifications: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteAutoTagging: PropTypes.func.isRequired, + onCloneAutoTaggingPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css new file mode 100644 index 000000000..40950bd5f --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css @@ -0,0 +1,21 @@ +.autoTaggings { + display: flex; + flex-wrap: wrap; +} + +.addAutoTagging { + composes: autoTagging from '~./AutoTagging.css'; + + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--cardCenterBackgroundColor); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js new file mode 100644 index 000000000..9c9005e7b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js @@ -0,0 +1,103 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import { icons } from 'Helpers/Props'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { cloneAutoTagging, deleteAutoTagging, fetchAutoTaggings } from 'Store/Actions/settingsActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import sortByName from 'Utilities/Array/sortByName'; +import AutoTagging from './AutoTagging'; +import EditAutoTaggingModal from './EditAutoTaggingModal'; +import styles from './AutoTaggings.css'; + +export default function AutoTaggings() { + const { + error, + items, + isDeleting, + isFetching, + isPopulated + } = useSelector( + createSortedSectionSelector('settings.autoTaggings', sortByName) + ); + + const tagList = useSelector(createTagsSelector()); + const dispatch = useDispatch(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [tagsFromId, setTagsFromId] = useState(undefined); + + const onClonePress = useCallback((id) => { + dispatch(cloneAutoTagging({ id })); + + setTagsFromId(id); + setIsEditModalOpen(true); + }, [dispatch, setIsEditModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback((id) => { + dispatch(deleteAutoTagging({ id })); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchAutoTaggings()); + dispatch(fetchRootFolders()); + }, [dispatch]); + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + +
+
+ ); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js new file mode 100644 index 000000000..c6f810785 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditAutoTaggingModalContent from './EditAutoTaggingModalContent'; + +export default function EditAutoTaggingModal(props) { + const { + isOpen, + onModalClose: onOriginalModalClose, + ...otherProps + } = props; + + const dispatch = useDispatch(); + const [height, setHeight] = useState('auto'); + + const onContentHeightChange = useCallback((h) => { + if (height === 'auto' || h > height) { + setHeight(h); + } + }, [height, setHeight]); + + const onModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'settings.autoTaggings' })); + onOriginalModalClose(); + }, [dispatch, onOriginalModalClose]); + + return ( + + + + ); +} + +EditAutoTaggingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css new file mode 100644 index 000000000..a197dbcd4 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css @@ -0,0 +1,27 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.rightButtons { + justify-content: flex-end; + margin-right: auto; +} + +.addSpecification { + composes: autoTagging from '~./AutoTagging.css'; + + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--cardCenterBackgroundColor); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js new file mode 100644 index 000000000..28bea7d9c --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js @@ -0,0 +1,268 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { icons, inputTypes, kinds } from 'Helpers/Props'; +import { + cloneAutoTaggingSpecification, + deleteAutoTaggingSpecification, + fetchAutoTaggingSpecifications, + saveAutoTagging, + setAutoTaggingValue +} from 'Store/Actions/settingsActions'; +import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector'; +import AddSpecificationModal from './Specifications/AddSpecificationModal'; +import EditSpecificationModal from './Specifications/EditSpecificationModal'; +import Specification from './Specifications/Specification'; +import styles from './EditAutoTaggingModalContent.css'; + +export default function EditAutoTaggingModalContent(props) { + const { + id, + tagsFromId, + onModalClose, + onDeleteAutoTaggingPress + } = props; + + const { + error, + item, + isFetching, + isSaving, + saveError, + validationErrors, + validationWarnings + } = useSelector(createProviderSettingsSelectorHook('autoTaggings', id)); + + const { + isPopulated: specificationsPopulated, + items: specifications + } = useSelector((state) => state.settings.autoTaggingSpecifications); + + const dispatch = useDispatch(); + const [isAddSpecificationModalOpen, setIsAddSpecificationModalOpen] = useState(false); + const [isEditSpecificationModalOpen, setIsEditSpecificationModalOpen] = useState(false); + // const [isImportAutoTaggingModalOpen, setIsImportAutoTaggingModalOpen] = useState(false); + + const onAddSpecificationPress = useCallback(() => { + setIsAddSpecificationModalOpen(true); + }, [setIsAddSpecificationModalOpen]); + + const onAddSpecificationModalClose = useCallback(({ specificationSelected = false } = {}) => { + setIsAddSpecificationModalOpen(false); + setIsEditSpecificationModalOpen(specificationSelected); + }, [setIsAddSpecificationModalOpen]); + + const onEditSpecificationModalClose = useCallback(() => { + setIsEditSpecificationModalOpen(false); + }, [setIsEditSpecificationModalOpen]); + + const onInputChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingValue({ name, value })); + }, [dispatch]); + + const onSavePress = useCallback(() => { + dispatch(saveAutoTagging({ id })); + }, [dispatch, id]); + + const onCloneSpecificationPress = useCallback((specId) => { + dispatch(cloneAutoTaggingSpecification({ id: specId })); + }, [dispatch]); + + const onConfirmDeleteSpecification = useCallback((specId) => { + dispatch(deleteAutoTaggingSpecification({ id: specId })); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchAutoTaggingSpecifications({ id: tagsFromId || id })); + }, [id, tagsFromId, dispatch]); + + const isSavingRef = useRef(); + + useEffect(() => { + if (isSavingRef.current && !isSaving && !saveError) { + onModalClose(); + } + + isSavingRef.current = isSaving; + }, [isSaving, saveError, onModalClose]); + + const { + name, + removeTagsAutomatically, + tags + } = item; + + return ( + + + + {id ? 'Edit Auto Tag' : 'Add Auto Tag'} + + + +
+ { + isFetching ? : null + } + + { + !isFetching && !!error ? +
+ {'Unable to add a new auto tag, please try again.'} +
: + null + } + + { + !isFetching && !error && specificationsPopulated ? +
+
+ + + Name + + + + + + + {'Remove Tags Automatically'} + + + + + + Tags + + + +
+ +
+
+ { + specifications.map((tag) => { + return ( + + ); + }) + } + + +
+ +
+
+
+
+ + + + + + {/* */} + +
: + null + } +
+
+ +
+ { + id ? + : + null + } + + {/* */} +
+ + + + + Save + +
+
+ ); +} + +EditAutoTaggingModalContent.propTypes = { + id: PropTypes.number, + tagsFromId: PropTypes.number, + onModalClose: PropTypes.func.isRequired, + onDeleteAutoTaggingPress: PropTypes.func +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css new file mode 100644 index 000000000..eabcae750 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css @@ -0,0 +1,44 @@ +.specification { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js new file mode 100644 index 000000000..ac23b9291 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +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 { sizes } from 'Helpers/Props'; +import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem'; +import styles from './AddSpecificationItem.css'; + +export default function AddSpecificationItem(props) { + const { + implementation, + implementationName, + infoLink, + presets, + onSpecificationSelect + } = props; + + const onWrappedSpecificationSelect = useCallback(() => { + onSpecificationSelect({ implementation }); + }, [implementation, onSpecificationSelect]); + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets ? + + + + + + + + { + presets.map((preset, index) => { + return ( + + ); + }) + } + + + : + null + } + + { + infoLink ? + : + null + } +
+
+
+ ); +} + +AddSpecificationItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string, + presets: PropTypes.arrayOf(PropTypes.object), + onSpecificationSelect: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js new file mode 100644 index 000000000..1a8c115f0 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddSpecificationModalContent from './AddSpecificationModalContent'; + +function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddSpecificationModal; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css new file mode 100644 index 000000000..d51349ea9 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css @@ -0,0 +1,5 @@ +.specifications { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js new file mode 100644 index 000000000..824e7452d --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { kinds } from 'Helpers/Props'; +import { + fetchAutoTaggingSpecificationSchema, + selectAutoTaggingSpecificationSchema +} from 'Store/Actions/settingsActions'; +import AddSpecificationItem from './AddSpecificationItem'; +import styles from './AddSpecificationModalContent.css'; + +export default function AddSpecificationModalContent(props) { + const { + onModalClose + } = props; + + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = useSelector( + (state) => state.settings.autoTaggingSpecifications + ); + + const dispatch = useDispatch(); + + const onSpecificationSelect = useCallback(({ implementation, name }) => { + dispatch(selectAutoTaggingSpecificationSchema({ implementation, presetName: name })); + onModalClose({ specificationSelected: true }); + }, [dispatch, onModalClose]); + + useEffect(() => { + dispatch(fetchAutoTaggingSpecificationSchema()); + }, [dispatch]); + + return ( + + + Add Condition + + + + { + isSchemaFetching ? : null + } + + { + !isSchemaFetching && !!schemaError ? +
+ {'Unable to add a new condition, please try again.'} +
: + null + } + + { + isSchemaPopulated && !schemaError ? +
+ + +
+ {'Sonarr supports the follow properties for auto tagging rules'} +
+
+ +
+ { + schema.map((specification) => { + return ( + + ); + }) + } +
+ +
: + null + } +
+ + + + +
+ ); +} + +AddSpecificationModalContent.propTypes = { + onModalClose: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js new file mode 100644 index 000000000..b043ddf06 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +export default function AddSpecificationPresetMenuItem(props) { + const { + name, + implementation, + onPress, + ...otherProps + } = props; + + const onWrappedPress = useCallback(() => { + onPress({ + name, + implementation + }); + }, [name, implementation, onPress]); + + return ( + + {name} + + ); +} + +AddSpecificationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js new file mode 100644 index 000000000..16ed4daec --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditSpecificationModalContent from './EditSpecificationModalContent'; + +function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + const dispatch = useDispatch(); + + const onWrappedModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'settings.autoTaggingSpecifications' })); + onModalClose(); + }, [onModalClose, dispatch]); + + return ( + + + + ); +} + +EditSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSpecificationModal; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js new file mode 100644 index 000000000..ce8851d75 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js @@ -0,0 +1,188 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { + clearAutoTaggingSpecificationPending, + saveAutoTaggingSpecification, + setAutoTaggingSpecificationFieldValue, + setAutoTaggingSpecificationValue +} from 'Store/Actions/settingsActions'; +import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector'; +import styles from './EditSpecificationModalContent.css'; + +function EditSpecificationModalContent(props) { + const { + id, + onDeleteSpecificationPress, + onModalClose + } = props; + + const advancedSettings = useSelector((state) => state.settings.advancedSettings); + + const { + item, + ...otherFormProps + } = useSelector( + createProviderSettingsSelectorHook('autoTaggingSpecifications', id) + ); + + const dispatch = useDispatch(); + + const onInputChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingSpecificationValue({ name, value })); + }, [dispatch]); + + const onFieldChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingSpecificationFieldValue({ name, value })); + }, [dispatch]); + + const onCancelPress = useCallback(({ name, value }) => { + dispatch(clearAutoTaggingSpecificationPending()); + onModalClose(); + }, [dispatch, onModalClose]); + + const onSavePress = useCallback(({ name, value }) => { + dispatch(saveAutoTaggingSpecification({ id })); + onModalClose(); + }, [dispatch, id, onModalClose]); + + const { + implementationName, + name, + negate, + required, + fields + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Condition - ${implementationName}`} + + + +
+ { + fields && fields.some((x) => x.label === 'Regular Expression') && + +
+
\\^$.|?*+()[{ have special meanings and need escaping with a \\' }} /> + {'More details'} {'Here'} +
+
+ {'Regular expressions can be tested '} + Here +
+ + } + + + + Name + + + + + + { + fields && fields.map((field) => { + return ( + + ); + }) + } + + + + Negate + + + + + + + + Required + + + + + + + + { + id ? + : + null + } + + + + + Save + + + + ); +} + +EditSpecificationModalContent.propTypes = { + id: PropTypes.number, + onDeleteSpecificationPress: PropTypes.func, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSpecificationModalContent; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js new file mode 100644 index 000000000..8f27b74e0 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearAutoTaggingSpecificationPending, saveAutoTaggingSpecification, setAutoTaggingSpecificationFieldValue, setAutoTaggingSpecificationValue } from 'Store/Actions/settingsActions'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import EditSpecificationModalContent from './EditSpecificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('autoTaggingSpecifications'), + (advancedSettings, specification) => { + return { + advancedSettings, + ...specification + }; + } + ); +} + +const mapDispatchToProps = { + setAutoTaggingSpecificationValue, + setAutoTaggingSpecificationFieldValue, + saveAutoTaggingSpecification, + clearAutoTaggingSpecificationPending +}; + +class EditSpecificationModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setAutoTaggingSpecificationValue({ name, value }); + }; + + onFieldChange = ({ name, value }) => { + this.props.setAutoTaggingSpecificationFieldValue({ name, value }); + }; + + onCancelPress = () => { + this.props.clearAutoTaggingSpecificationPending(); + this.props.onModalClose(); + }; + + onSavePress = () => { + this.props.saveAutoTaggingSpecification({ id: this.props.id }); + this.props.onModalClose(); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +EditSpecificationModalContentConnector.propTypes = { + id: PropTypes.number, + item: PropTypes.object.isRequired, + setAutoTaggingSpecificationValue: PropTypes.func.isRequired, + setAutoTaggingSpecificationFieldValue: PropTypes.func.isRequired, + clearAutoTaggingSpecificationPending: PropTypes.func.isRequired, + saveAutoTaggingSpecification: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditSpecificationModalContentConnector); diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css new file mode 100644 index 000000000..e329fc313 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css @@ -0,0 +1,38 @@ +.autoTagging { + composes: card from '~Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + +.labels { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js new file mode 100644 index 000000000..b53bc74b6 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import { icons, kinds } from 'Helpers/Props'; +import EditSpecificationModal from './EditSpecificationModal'; +import styles from './Specification.css'; + +export default function Specification(props) { + const { + id, + implementationName, + name, + required, + negate, + onConfirmDeleteSpecification, + onCloneSpecificationPress + } = props; + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onDeletePress = useCallback(() => { + setIsEditModalOpen(false); + setIsDeleteModalOpen(true); + }, [setIsEditModalOpen, setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onConfirmDelete = useCallback(() => { + onConfirmDeleteSpecification(id); + }, [id, onConfirmDeleteSpecification]); + + const onClonePress = useCallback(() => { + onCloneSpecificationPress(id); + }, [id, onCloneSpecificationPress]); + + return ( + +
+
+ {name} +
+ + +
+ +
+ + + { + negate ? + : + null + } + + { + required ? + : + null + } +
+ + + + +
+ ); +} + +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 +}; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index 48a008960..6f4d8f02c 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -21,6 +21,7 @@ function TagDetailsModalContent(props) { notifications, releaseProfiles, indexers, + autoTags, onModalClose, onDeleteTagPress } = props; @@ -177,6 +178,22 @@ function TagDetailsModalContent(props) { : null } + + { + autoTags.length ? +
+ { + autoTags.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
: + null + } @@ -211,6 +228,7 @@ TagDetailsModalContent.propTypes = { notifications: PropTypes.arrayOf(PropTypes.object).isRequired, releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, indexers: PropTypes.arrayOf(PropTypes.object).isRequired, + autoTags: PropTypes.arrayOf(PropTypes.object).isRequired, onModalClose: PropTypes.func.isRequired, onDeleteTagPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js index 71948e1e2..ce138b5f7 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -77,6 +77,14 @@ function createMatchingIndexersSelector() { ); } +function createMatchingAutoTagsSelector() { + return createSelector( + (state, { autoTagIds }) => autoTagIds, + (state) => state.settings.autoTaggings.items, + findMatchingItems + ); +} + function createMapStateToProps() { return createSelector( createMatchingSeriesSelector(), @@ -85,14 +93,16 @@ function createMapStateToProps() { createMatchingNotificationsSelector(), createMatchingReleaseProfilesSelector(), createMatchingIndexersSelector(), - (series, delayProfiles, importLists, notifications, releaseProfiles, indexers) => { + createMatchingAutoTagsSelector(), + (series, delayProfiles, importLists, notifications, releaseProfiles, indexers, autoTags) => { return { series, delayProfiles, importLists, notifications, releaseProfiles, - indexers + indexers, + autoTags }; } ); diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js index 59e6af42d..b16a58f75 100644 --- a/frontend/src/Settings/Tags/Tag.js +++ b/frontend/src/Settings/Tags/Tag.js @@ -4,6 +4,7 @@ import Card from 'Components/Card'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import { kinds } from 'Helpers/Props'; import TagDetailsModal from './Details/TagDetailsModal'; +import TagInUse from './TagInUse'; import styles from './Tag.css'; class Tag extends Component { @@ -57,6 +58,7 @@ class Tag extends Component { notificationIds, restrictionIds, indexerIds, + autoTagIds, seriesIds } = this.props; @@ -71,6 +73,7 @@ class Tag extends Component { notificationIds.length || restrictionIds.length || indexerIds.length || + autoTagIds.length || seriesIds.length ); @@ -85,56 +88,46 @@ class Tag extends Component {
{ - isTagUsed && + isTagUsed ?
- { - seriesIds.length ? -
- {seriesIds.length} series -
: - null - } - - { - delayProfileIds.length ? -
- {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'} -
: - null - } - - { - importListIds.length ? -
- {importListIds.length} import list{importListIds.length > 1 && 's'} -
: - null - } - - { - notificationIds.length ? -
- {notificationIds.length} connection{notificationIds.length > 1 && 's'} -
: - null - } - - { - restrictionIds.length ? -
- {restrictionIds.length} release profile{restrictionIds.length > 1 && 's'} -
: - null - } - - { - indexerIds.length ? -
- {indexerIds.length} indexer{indexerIds.length > 1 && 's'} -
: - null - } -
+ + + + + + + + + + + + + + : + null } { @@ -153,6 +146,7 @@ class Tag extends Component { notificationIds={notificationIds} restrictionIds={restrictionIds} indexerIds={indexerIds} + autoTagIds={autoTagIds} isOpen={isDetailsModalOpen} onModalClose={this.onDetailsModalClose} onDeleteTagPress={this.onDeleteTagPress} @@ -180,6 +174,7 @@ Tag.propTypes = { notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired, restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired, indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired, + autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired, seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, onConfirmDeleteTag: PropTypes.func.isRequired }; @@ -190,6 +185,7 @@ Tag.defaultProps = { notificationIds: [], restrictionIds: [], indexerIds: [], + autoTagIds: [], seriesIds: [] }; diff --git a/frontend/src/Settings/Tags/TagInUse.js b/frontend/src/Settings/Tags/TagInUse.js new file mode 100644 index 000000000..cc0b968b5 --- /dev/null +++ b/frontend/src/Settings/Tags/TagInUse.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +export default function TagInUse(props) { + const { + label, + count, + shouldPluralize = true + } = props; + + if (count === 0) { + return null; + } + + if (count > 1 && shouldPluralize) { + return ( +
+ {count} {label}s +
+ ); + } + + return ( +
+ {count} {label} +
+ ); +} + +TagInUse.propTypes = { + label: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + shouldPluralize: PropTypes.bool +}; diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js index 786b4b40b..1aa555e67 100644 --- a/frontend/src/Settings/Tags/TagSettings.js +++ b/frontend/src/Settings/Tags/TagSettings.js @@ -2,6 +2,7 @@ import React from 'react'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import AutoTaggings from './AutoTagging/AutoTaggings'; import TagsConnector from './TagsConnector'; function TagSettings() { @@ -13,6 +14,7 @@ function TagSettings() { + ); diff --git a/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js new file mode 100644 index 000000000..cfc919c7d --- /dev/null +++ b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js @@ -0,0 +1,193 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import { createThunk } from 'Store/thunks'; +import getNextId from 'Utilities/State/getNextId'; +import getProviderState from 'Utilities/State/getProviderState'; +import getSectionState from 'Utilities/State/getSectionState'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { removeItem, set, update, updateItem } from '../baseActions'; + +// +// Variables + +const section = 'settings.autoTaggingSpecifications'; + +// +// Actions Types + +export const FETCH_AUTO_TAGGING_SPECIFICATIONS = 'settings/autoTaggingSpecifications/fetchAutoTaggingSpecifications'; +export const FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA = 'settings/autoTaggingSpecifications/fetchAutoTaggingSpecificationSchema'; +export const SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA = 'settings/autoTaggingSpecifications/selectAutoTaggingSpecificationSchema'; +export const SET_AUTO_TAGGING_SPECIFICATION_VALUE = 'settings/autoTaggingSpecifications/setAutoTaggingSpecificationValue'; +export const SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE = 'settings/autoTaggingSpecifications/setAutoTaggingSpecificationFieldValue'; +export const SAVE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/saveAutoTaggingSpecification'; +export const DELETE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/deleteAutoTaggingSpecification'; +export const DELETE_ALL_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/deleteAllAutoTaggingSpecification'; +export const CLONE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/cloneAutoTaggingSpecification'; +export const CLEAR_AUTO_TAGGING_SPECIFICATIONS = 'settings/autoTaggingSpecifications/clearAutoTaggingSpecifications'; +export const CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING = 'settings/autoTaggingSpecifications/clearAutoTaggingSpecificationPending'; +// +// Action Creators + +export const fetchAutoTaggingSpecifications = createThunk(FETCH_AUTO_TAGGING_SPECIFICATIONS); +export const fetchAutoTaggingSpecificationSchema = createThunk(FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA); +export const selectAutoTaggingSpecificationSchema = createAction(SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA); + +export const saveAutoTaggingSpecification = createThunk(SAVE_AUTO_TAGGING_SPECIFICATION); +export const deleteAutoTaggingSpecification = createThunk(DELETE_AUTO_TAGGING_SPECIFICATION); +export const deleteAllAutoTaggingSpecification = createThunk(DELETE_ALL_AUTO_TAGGING_SPECIFICATION); + +export const setAutoTaggingSpecificationValue = createAction(SET_AUTO_TAGGING_SPECIFICATION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setAutoTaggingSpecificationFieldValue = createAction(SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneAutoTaggingSpecification = createAction(CLONE_AUTO_TAGGING_SPECIFICATION); + +export const clearAutoTaggingSpecification = createAction(CLEAR_AUTO_TAGGING_SPECIFICATIONS); + +export const clearAutoTaggingSpecificationPending = createThunk(CLEAR_AUTO_TAGGING_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_AUTO_TAGGING_SPECIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/autoTagging/schema'), + + [FETCH_AUTO_TAGGING_SPECIFICATIONS]: (getState, payload, dispatch) => { + let tags = []; + if (payload.id) { + const cfState = getSectionState(getState(), 'settings.autoTaggings', 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_AUTO_TAGGING_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.autoTaggingSpecifications.items); + } + + dispatch(batchActions([ + updateItem({ section, ...saveData }), + set({ + section, + pendingChanges: {} + }) + ])); + }, + + [DELETE_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => { + const id = payload.id; + return dispatch(removeItem({ section, id })); + }, + + [DELETE_ALL_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => { + return dispatch(set({ + section, + items: [] + })); + }, + + [CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING]: (getState, payload, dispatch) => { + return dispatch(set({ + section, + pendingChanges: {} + })); + } + }, + + // + // Reducers + + reducers: { + [SET_AUTO_TAGGING_SPECIFICATION_VALUE]: createSetSettingValueReducer(section), + [SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + return selectedSchema; + }); + }, + + [CLONE_AUTO_TAGGING_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_AUTO_TAGGING_SPECIFICATIONS]: createClearReducer(section, { + isPopulated: false, + error: null, + items: [] + }) + } +}; diff --git a/frontend/src/Store/Actions/Settings/autoTaggings.js b/frontend/src/Store/Actions/Settings/autoTaggings.js new file mode 100644 index 000000000..35b3d4149 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/autoTaggings.js @@ -0,0 +1,109 @@ +import { createAction } from 'redux-actions'; +import { set } from 'Store/Actions/baseActions'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +// +// Variables + +const section = 'settings.autoTaggings'; + +// +// Actions Types + +export const FETCH_AUTO_TAGGINGS = 'settings/autoTaggings/fetchAutoTaggings'; +export const SAVE_AUTO_TAGGING = 'settings/autoTaggings/saveAutoTagging'; +export const DELETE_AUTO_TAGGING = 'settings/autoTaggings/deleteAutoTagging'; +export const SET_AUTO_TAGGING_VALUE = 'settings/autoTaggings/setAutoTaggingValue'; +export const CLONE_AUTO_TAGGING = 'settings/autoTaggings/cloneAutoTagging'; + +// +// Action Creators + +export const fetchAutoTaggings = createThunk(FETCH_AUTO_TAGGINGS); +export const saveAutoTagging = createThunk(SAVE_AUTO_TAGGING); +export const deleteAutoTagging = createThunk(DELETE_AUTO_TAGGING); + +export const setAutoTaggingValue = createAction(SET_AUTO_TAGGING_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneAutoTagging = createAction(CLONE_AUTO_TAGGING); + +// +// Details + +export default { + + // + // State + + defaultState: { + isSchemaFetching: false, + isSchemaPopulated: false, + isFetching: false, + isPopulated: false, + schema: { + removeTagsAutomatically: false, + tags: [] + }, + error: null, + isDeleting: false, + deleteError: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_AUTO_TAGGINGS]: createFetchHandler(section, '/autoTagging'), + + [DELETE_AUTO_TAGGING]: createRemoveItemHandler(section, '/autoTagging'), + + [SAVE_AUTO_TAGGING]: (getState, payload, dispatch) => { + // move the format tags in as a pending change + const state = getState(); + const pendingChanges = state.settings.autoTaggings.pendingChanges; + pendingChanges.specifications = state.settings.autoTaggingSpecifications.items; + dispatch(set({ + section, + pendingChanges + })); + + createSaveProviderHandler(section, '/autoTagging')(getState, payload, dispatch); + } + }, + + // + // Reducers + + reducers: { + [SET_AUTO_TAGGING_VALUE]: createSetSettingValueReducer(section), + + [CLONE_AUTO_TAGGING]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 54dbf1206..c1e6e5135 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -1,6 +1,8 @@ import { createAction } from 'redux-actions'; import { handleThunks } from 'Store/thunks'; import createHandleActions from './Creators/createHandleActions'; +import autoTaggings from './Settings/autoTaggings'; +import autoTaggingSpecifications from './Settings/autoTaggingSpecifications'; import customFormats from './Settings/customFormats'; import customFormatSpecifications from './Settings/customFormatSpecifications'; import delayProfiles from './Settings/delayProfiles'; @@ -23,6 +25,8 @@ import releaseProfiles from './Settings/releaseProfiles'; import remotePathMappings from './Settings/remotePathMappings'; import ui from './Settings/ui'; +export * from './Settings/autoTaggingSpecifications'; +export * from './Settings/autoTaggings'; export * from './Settings/customFormatSpecifications.js'; export * from './Settings/customFormats'; export * from './Settings/delayProfiles'; @@ -55,7 +59,8 @@ export const section = 'settings'; export const defaultState = { advancedSettings: false, - + autoTaggingSpecifications: autoTaggingSpecifications.defaultState, + autoTaggings: autoTaggings.defaultState, customFormatSpecifications: customFormatSpecifications.defaultState, customFormats: customFormats.defaultState, delayProfiles: delayProfiles.defaultState, @@ -97,6 +102,8 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); // Action Handlers export const actionHandlers = handleThunks({ + ...autoTaggingSpecifications.actionHandlers, + ...autoTaggings.actionHandlers, ...customFormatSpecifications.actionHandlers, ...customFormats.actionHandlers, ...delayProfiles.actionHandlers, @@ -129,6 +136,8 @@ export const reducers = createHandleActions({ return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); }, + ...autoTaggingSpecifications.reducers, + ...autoTaggings.reducers, ...customFormatSpecifications.reducers, ...customFormats.reducers, ...delayProfiles.reducers, diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js index 46659609f..f5ac9bad5 100644 --- a/frontend/src/Store/Selectors/createProviderSettingsSelector.js +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js @@ -2,62 +2,70 @@ import _ from 'lodash'; import { createSelector } from 'reselect'; import selectSettings from 'Store/Selectors/selectSettings'; -function createProviderSettingsSelector(sectionName) { +function selector(id, section) { + if (!id) { + const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; + const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); + + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges, + ...settings, + item: settings.settings + }; + } + + const { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + ...settings, + item: settings.settings + }; +} + +export default function createProviderSettingsSelector(sectionName) { return createSelector( (state, { id }) => id, (state) => state.settings[sectionName], - (id, section) => { - if (!id) { - const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; - const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); - - const { - isSchemaFetching: isFetching, - isSchemaPopulated: isPopulated, - schemaError: error, - isSaving, - saveError, - isTesting, - pendingChanges - } = section; - - return { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - pendingChanges, - ...settings, - item: settings.settings - }; - } - - const { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - pendingChanges - } = section; - - const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); - - return { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - ...settings, - item: settings.settings - }; - } + (id, section) => selector(id, section) + ); +} + +export function createProviderSettingsSelectorHook(sectionName, id) { + return createSelector( + (state) => state.settings[sectionName], + (section) => selector(id, section) ); } -export default createProviderSettingsSelector; diff --git a/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs b/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs new file mode 100644 index 000000000..561998f1a --- /dev/null +++ b/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.AutoTagging +{ + [TestFixture] + public class AutoTaggingServiceFixture : CoreTest + { + private Series _series; + private AutoTag _tag; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew() + .With(s => s.Genres = new List { "Comedy" }) + .Build(); + + _tag = new AutoTag + { + Name = "Test", + Specifications = new List + { + new GenreSpecification + { + Name = "Genre", + Value = new List + { + "Comedy" + } + } + }, + Tags = new HashSet { 1 }, + RemoveTagsAutomatically = false + }; + } + + private void GivenAutoTags(List autoTags) + { + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(autoTags); + } + + [Test] + public void should_not_have_changes_if_there_are_no_auto_tags() + { + GivenAutoTags(new List()); + + var result = Subject.GetTagChanges(_series); + + result.TagsToAdd.Should().BeEmpty(); + result.TagsToRemove.Should().BeEmpty(); + } + + [Test] + public void should_have_tags_to_add_if_series_does_not_have_match_tag() + { + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_series); + + result.TagsToAdd.Should().HaveCount(1); + result.TagsToAdd.Should().Contain(1); + result.TagsToRemove.Should().BeEmpty(); + } + + [Test] + public void should_not_have_tags_to_remove_if_series_has_matching_tag_but_remove_is_false() + { + _series.Tags = new HashSet { 1 }; + _series.Genres = new List { "NotComedy" }; + + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_series); + + result.TagsToAdd.Should().BeEmpty(); + result.TagsToRemove.Should().BeEmpty(); + } + + [Test] + public void should_have_tags_to_remove_if_series_has_matching_tag_and_remove_is_true() + { + _series.Tags = new HashSet { 1 }; + _series.Genres = new List { "NotComedy" }; + + _tag.RemoveTagsAutomatically = true; + + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_series); + + result.TagsToAdd.Should().BeEmpty(); + result.TagsToRemove.Should().HaveCount(1); + result.TagsToRemove.Should().Contain(1); + } + + [Test] + public void should_have_tags_to_add_if_series_does_not_have_match_tag_and_series_matches_all_rules() + { + _tag.Specifications.Add(new SeriesTypeSpecification + { + Name = "Series Type", + Value = (int)_series.SeriesType + }); + + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_series); + + result.TagsToAdd.Should().HaveCount(1); + result.TagsToAdd.Should().Contain(1); + result.TagsToRemove.Should().BeEmpty(); + } + + [Test] + public void should_match_if_specification_is_negated() + { + _series.Genres = new List { "NotComedy" }; + + _tag.Specifications.First().Negate = true; + + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_series); + + result.TagsToAdd.Should().HaveCount(1); + result.TagsToAdd.Should().Contain(1); + result.TagsToRemove.Should().BeEmpty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs index cd110555d..67cd1dd77 100644 --- a/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs +++ b/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs @@ -3,10 +3,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; using FizzWare.NBuilder; -using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Extensions; +using NzbDrone.Core.AutoTagging; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MetadataSource; @@ -44,6 +44,10 @@ namespace NzbDrone.Core.Test.TvTests Mocker.GetMock() .Setup(s => s.GetSeriesInfo(It.IsAny())) .Callback(p => { throw new SeriesNotFoundException(p); }); + + Mocker.GetMock() + .Setup(s => s.GetTagChanges(_series)) + .Returns(new AutoTaggingChanges()); } private void GivenNewSeriesInfo(Series series) diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 0d2700df8..8a3973ea2 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -64,7 +64,8 @@ namespace NzbDrone.Core.Annotations Captcha, OAuth, Device, - TagSelect + TagSelect, + RootFolder } public enum HiddenType diff --git a/src/NzbDrone.Core/AutoTagging/AutoTag.cs b/src/NzbDrone.Core/AutoTagging/AutoTag.cs new file mode 100644 index 000000000..07e36afe7 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/AutoTag.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using NzbDrone.Core.AutoTagging.Specifications; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.AutoTagging +{ + public class AutoTag : ModelBase + { + public AutoTag() + { + Tags = new HashSet(); + } + + public string Name { get; set; } + public List Specifications { get; set; } + public bool RemoveTagsAutomatically { get; set; } + public HashSet Tags { get; set; } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs b/src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs new file mode 100644 index 000000000..aa28eecce --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.AutoTagging +{ + public class AutoTaggingChanges + { + public HashSet TagsToAdd { get; set; } + public HashSet TagsToRemove { get; set; } + + public AutoTaggingChanges() + { + TagsToAdd = new HashSet(); + TagsToRemove = new HashSet(); + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs b/src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs new file mode 100644 index 000000000..4a5b1c9a3 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.AutoTagging +{ + public interface IAutoTaggingRepository : IBasicRepository + { + } + + public class AutoTaggingRepository : BasicRepository, IAutoTaggingRepository + { + public AutoTaggingRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs b/src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs new file mode 100644 index 000000000..10c10060b --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.RootFolders; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.AutoTagging +{ + public interface IAutoTaggingService + { + void Update(AutoTag autoTag); + AutoTag Insert(AutoTag autoTag); + List All(); + AutoTag GetById(int id); + void Delete(int id); + List AllForTag(int tagId); + AutoTaggingChanges GetTagChanges(Series series); + } + + public class AutoTaggingService : IAutoTaggingService + { + private readonly IAutoTaggingRepository _repository; + private readonly RootFolderService _rootFolderService; + private readonly ICached> _cache; + + public AutoTaggingService(IAutoTaggingRepository repository, + RootFolderService rootFolderService, + ICacheManager cacheManager) + { + _repository = repository; + _rootFolderService = rootFolderService; + _cache = cacheManager.GetCache>(typeof(AutoTag), "autoTags"); + } + + private Dictionary AllDictionary() + { + return _cache.Get("all", () => _repository.All().ToDictionary(m => m.Id)); + } + + public List All() + { + return AllDictionary().Values.ToList(); + } + + public AutoTag GetById(int id) + { + return AllDictionary()[id]; + } + + public void Update(AutoTag autoTag) + { + _repository.Update(autoTag); + _cache.Clear(); + } + + public AutoTag Insert(AutoTag autoTag) + { + var result = _repository.Insert(autoTag); + _cache.Clear(); + + return result; + } + + public void Delete(int id) + { + _repository.Delete(id); + _cache.Clear(); + } + + public List AllForTag(int tagId) + { + return All().Where(p => p.Tags.Contains(tagId)) + .ToList(); + } + + public AutoTaggingChanges GetTagChanges(Series series) + { + var autoTags = All(); + var changes = new AutoTaggingChanges(); + + if (autoTags.Empty()) + { + return changes; + } + + // Set the root folder path on the series + series.RootFolderPath = _rootFolderService.GetBestRootFolderPath(series.Path); + + foreach (var autoTag in autoTags) + { + var specificationMatches = autoTag.Specifications + .GroupBy(t => t.GetType()) + .Select(g => new SpecificationMatchesGroup + { + Matches = g.ToDictionary(t => t, t => t.IsSatisfiedBy(series)) + }) + .ToList(); + + var allMatch = specificationMatches.All(x => x.DidMatch); + var tags = autoTag.Tags; + + if (allMatch) + { + foreach (var tag in tags) + { + if (!series.Tags.Contains(tag)) + { + changes.TagsToAdd.Add(tag); + } + } + + continue; + } + + if (autoTag.RemoveTagsAutomatically) + { + foreach (var tag in tags) + { + changes.TagsToRemove.Add(tag); + } + } + } + + return changes; + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs b/src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs new file mode 100644 index 000000000..96cd11881 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.AutoTagging.Specifications; + +namespace NzbDrone.Core.AutoTagging +{ + public class SpecificationMatchesGroup + { + public Dictionary Matches { get; set; } + + public bool DidMatch => !(Matches.Any(m => m.Key.Required && m.Value == false) || + Matches.All(m => m.Value == false)); + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/AutoTagSpecificationBase.cs b/src/NzbDrone.Core/AutoTagging/Specifications/AutoTagSpecificationBase.cs new file mode 100644 index 000000000..ddddc87c4 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/AutoTagSpecificationBase.cs @@ -0,0 +1,36 @@ +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public abstract class AutoTaggingSpecificationBase : IAutoTaggingSpecification + { + public abstract int Order { get; } + public abstract string ImplementationName { get; } + + public string Name { get; set; } + public bool Negate { get; set; } + public bool Required { get; set; } + + public IAutoTaggingSpecification Clone() + { + return (IAutoTaggingSpecification)MemberwiseClone(); + } + + public abstract NzbDroneValidationResult Validate(); + + public bool IsSatisfiedBy(Series series) + { + var match = IsSatisfiedByWithoutNegate(series); + + if (Negate) + { + match = !match; + } + + return match; + } + + protected abstract bool IsSatisfiedByWithoutNegate(Series series); + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs new file mode 100644 index 000000000..59173f312 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public class GenreSpecificationValidator : AbstractValidator + { + public GenreSpecificationValidator() + { + RuleFor(c => c.Value).NotEmpty(); + } + } + + public class GenreSpecification : AutoTaggingSpecificationBase + { + private static readonly GenreSpecificationValidator Validator = new GenreSpecificationValidator(); + + public override int Order => 1; + public override string ImplementationName => "Genre"; + + [FieldDefinition(1, Label = "Genre(s)", Type = FieldType.Tag)] + public IEnumerable Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(Series series) + { + return series.Genres.Any(genre => Value.Contains(genre)); + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/IAutoTagSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/IAutoTagSpecification.cs new file mode 100644 index 000000000..4240fe3e1 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/IAutoTagSpecification.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public interface IAutoTaggingSpecification + { + int Order { get; } + string ImplementationName { get; } + string Name { get; set; } + bool Negate { get; set; } + bool Required { get; set; } + NzbDroneValidationResult Validate(); + + IAutoTaggingSpecification Clone(); + bool IsSatisfiedBy(Series series); + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs new file mode 100644 index 000000000..07e08290a --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public class RootFolderSpecificationValidator : AbstractValidator + { + public RootFolderSpecificationValidator() + { + RuleFor(c => c.Value).IsValidPath(); + } + } + + public class RootFolderSpecification : AutoTaggingSpecificationBase + { + private static readonly RootFolderSpecificationValidator Validator = new RootFolderSpecificationValidator(); + + public override int Order => 1; + public override string ImplementationName => "Root Folder"; + + [FieldDefinition(1, Label = "Root Folder", Type = FieldType.RootFolder)] + public string Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(Series series) + { + return series.RootFolderPath.PathEquals(Value); + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/SeriesTypeSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/SeriesTypeSpecification.cs new file mode 100644 index 000000000..1116db529 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/SeriesTypeSpecification.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public class SeriesTypeSpecificationValidator : AbstractValidator + { + public SeriesTypeSpecificationValidator() + { + RuleFor(c => (SeriesTypes)c.Value).IsInEnum(); + } + } + + public class SeriesTypeSpecification : AutoTaggingSpecificationBase + { + private static readonly SeriesTypeSpecificationValidator Validator = new SeriesTypeSpecificationValidator(); + + public override int Order => 2; + public override string ImplementationName => "Series Type"; + + [FieldDefinition(1, Label = "Series Type", Type = FieldType.Select, SelectOptions = typeof(SeriesTypes))] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(Series series) + { + return (int)series.SeriesType == Value; + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Converters/AutoTagSpecificationConverter.cs b/src/NzbDrone.Core/Datastore/Converters/AutoTagSpecificationConverter.cs new file mode 100644 index 000000000..186076b07 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/AutoTagSpecificationConverter.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using NzbDrone.Core.AutoTagging.Specifications; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class AutoTaggingSpecificationConverter : JsonConverter> + { + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) + { + var wrapped = value.Select(x => new SpecificationWrapper + { + Type = x.GetType().Name, + Body = x + }); + + JsonSerializer.Serialize(writer, wrapped, options); + } + + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ValidateToken(reader, JsonTokenType.StartArray); + + var results = new List(); + + reader.Read(); // Advance to the first object after the StartArray token. This should be either a StartObject token, or the EndArray token. Anything else is invalid. + + while (reader.TokenType == JsonTokenType.StartObject) + { + reader.Read(); // Move to type property name + ValidateToken(reader, JsonTokenType.PropertyName); + + reader.Read(); // Move to type property value + ValidateToken(reader, JsonTokenType.String); + var typename = reader.GetString(); + + reader.Read(); // Move to body property name + ValidateToken(reader, JsonTokenType.PropertyName); + + reader.Read(); // Move to start of object (stored in this property) + ValidateToken(reader, JsonTokenType.StartObject); // Start of specification + + var type = Type.GetType($"NzbDrone.Core.AutoTagging.Specifications.{typename}, Sonarr.Core", true); + var item = (IAutoTaggingSpecification)JsonSerializer.Deserialize(ref reader, type, options); + results.Add(item); + + reader.Read(); // Move past end of body object + reader.Read(); // Move past end of 'wrapper' object + } + + ValidateToken(reader, JsonTokenType.EndArray); + + return results; + } + + // Helper function for validating where you are in the JSON + private void ValidateToken(Utf8JsonReader reader, JsonTokenType tokenType) + { + if (reader.TokenType != tokenType) + { + throw new JsonException($"Invalid token: Was expecting a '{tokenType}' token but received a '{reader.TokenType}' token"); + } + } + + private class SpecificationWrapper + { + public string Type { get; set; } + public object Body { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/179_add_auto_tagging.cs b/src/NzbDrone.Core/Datastore/Migration/179_add_auto_tagging.cs new file mode 100644 index 000000000..7c1f75b35 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/179_add_auto_tagging.cs @@ -0,0 +1,18 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(179)] + public class add_auto_tagging : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("AutoTagging") + .WithColumn("Name").AsString().Unique() + .WithColumn("Specifications").AsString().WithDefaultValue("[]") + .WithColumn("RemoveTagsAutomatically").AsBoolean().WithDefaultValue(false) + .WithColumn("Tags").AsString().WithDefaultValue("[]"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 16289de30..538a244d6 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -4,6 +4,7 @@ using System.Linq; using Dapper; using NzbDrone.Common.Reflection; using NzbDrone.Core.Authentication; +using NzbDrone.Core.AutoTagging.Specifications; using NzbDrone.Core.Blocklisting; using NzbDrone.Core.Configuration; using NzbDrone.Core.CustomFilters; @@ -158,6 +159,8 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("UpdateHistory").RegisterModel(); Mapper.Entity("ImportListExclusions").RegisterModel(); + + Mapper.Entity("AutoTagging").RegisterModel(); } private static void RegisterMappers() @@ -171,6 +174,7 @@ namespace NzbDrone.Core.Datastore SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new QualityIntConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new CustomFormatIntConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new CustomFormatSpecificationListConverter())); + SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new AutoTaggingSpecificationConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter(new QualityIntConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>()); diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index 9323bdb21..1f5aac28a 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers { using (var mapper = _database.OpenConnection()) { - var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers" } + var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging" } .SelectMany(v => GetUsedTags(v, mapper)) .Distinct() .ToArray(); diff --git a/src/NzbDrone.Core/MediaCover/CoverAlreadyExistsSpecification.cs b/src/NzbDrone.Core/MediaCover/CoverAlreadyExistsSpecification.cs index a43075bfe..0b9d1f27c 100644 --- a/src/NzbDrone.Core/MediaCover/CoverAlreadyExistsSpecification.cs +++ b/src/NzbDrone.Core/MediaCover/CoverAlreadyExistsSpecification.cs @@ -1,4 +1,4 @@ -using NzbDrone.Common.Disk; +using NzbDrone.Common.Disk; using NzbDrone.Common.Http; namespace NzbDrone.Core.MediaCover diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index 574f3baff..387f4db47 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -13,13 +13,8 @@ namespace NzbDrone.Core.Tags public List DelayProfileIds { get; set; } public List ImportListIds { get; set; } public List IndexerIds { get; set; } + public List AutoTagIds { get; set; } - public bool InUse - { - get - { - return SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any(); - } - } + public bool InUse => SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any() || AutoTagIds.Any(); } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index 863d248af..bf15ecfd9 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.AutoTagging; using NzbDrone.Core.Datastore; using NzbDrone.Core.ImportLists; using NzbDrone.Core.Indexers; @@ -35,6 +36,7 @@ namespace NzbDrone.Core.Tags private readonly IReleaseProfileService _releaseProfileService; private readonly ISeriesService _seriesService; private readonly IIndexerFactory _indexerService; + private readonly IAutoTaggingService _autoTaggingService; public TagService(ITagRepository repo, IEventAggregator eventAggregator, @@ -43,7 +45,8 @@ namespace NzbDrone.Core.Tags INotificationFactory notificationFactory, IReleaseProfileService releaseProfileService, ISeriesService seriesService, - IIndexerFactory indexerService) + IIndexerFactory indexerService, + IAutoTaggingService autoTaggingService) { _repo = repo; _eventAggregator = eventAggregator; @@ -53,6 +56,7 @@ namespace NzbDrone.Core.Tags _releaseProfileService = releaseProfileService; _seriesService = seriesService; _indexerService = indexerService; + _autoTaggingService = autoTaggingService; } public Tag GetTag(int tagId) @@ -86,6 +90,7 @@ namespace NzbDrone.Core.Tags var restrictions = _releaseProfileService.AllForTag(tagId); var series = _seriesService.AllForTag(tagId); var indexers = _indexerService.AllForTag(tagId); + var autoTags = _autoTaggingService.AllForTag(tagId); return new TagDetails { @@ -96,7 +101,8 @@ namespace NzbDrone.Core.Tags NotificationIds = notifications.Select(c => c.Id).ToList(), RestrictionIds = restrictions.Select(c => c.Id).ToList(), SeriesIds = series.Select(c => c.Id).ToList(), - IndexerIds = indexers.Select(c => c.Id).ToList() + IndexerIds = indexers.Select(c => c.Id).ToList(), + AutoTagIds = autoTags.Select(c => c.Id).ToList() }; } @@ -109,6 +115,7 @@ namespace NzbDrone.Core.Tags var restrictions = _releaseProfileService.All(); var series = _seriesService.GetAllSeries(); var indexers = _indexerService.All(); + var autotags = _autoTaggingService.All(); var details = new List(); @@ -123,7 +130,8 @@ namespace NzbDrone.Core.Tags NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), SeriesIds = series.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), - IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList() + IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + AutoTagIds = autotags.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), }); } diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs index 07854da33..189223d1e 100644 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs @@ -5,6 +5,7 @@ using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.AutoTagging; using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; @@ -26,6 +27,7 @@ namespace NzbDrone.Core.Tv private readonly IDiskScanService _diskScanService; private readonly ICheckIfSeriesShouldBeRefreshed _checkIfSeriesShouldBeRefreshed; private readonly IConfigService _configService; + private readonly IAutoTaggingService _autoTaggingService; private readonly Logger _logger; public RefreshSeriesService(IProvideSeriesInfo seriesInfo, @@ -35,6 +37,7 @@ namespace NzbDrone.Core.Tv IDiskScanService diskScanService, ICheckIfSeriesShouldBeRefreshed checkIfSeriesShouldBeRefreshed, IConfigService configService, + IAutoTaggingService autoTaggingService, Logger logger) { _seriesInfo = seriesInfo; @@ -44,6 +47,7 @@ namespace NzbDrone.Core.Tv _diskScanService = diskScanService; _checkIfSeriesShouldBeRefreshed = checkIfSeriesShouldBeRefreshed; _configService = configService; + _autoTaggingService = autoTaggingService; _logger = logger; } @@ -189,6 +193,39 @@ namespace NzbDrone.Core.Tv } } + private void UpdateTags(Series series) + { + _logger.Trace("Updating tags for {0}", series); + + var tagsAdded = new HashSet(); + var tagsRemoved = new HashSet(); + var changes = _autoTaggingService.GetTagChanges(series); + + foreach (var tag in changes.TagsToRemove) + { + if (series.Tags.Contains(tag)) + { + series.Tags.Remove(tag); + tagsRemoved.Add(tag); + } + } + + foreach (var tag in changes.TagsToAdd) + { + if (!series.Tags.Contains(tag)) + { + series.Tags.Add(tag); + tagsAdded.Add(tag); + } + } + + if (tagsAdded.Any() || tagsRemoved.Any()) + { + _seriesService.UpdateSeries(series); + _logger.Debug("Updated tags for '{0}'. Added: {1}, Removed: {2}", series.Title, tagsAdded.Count, tagsRemoved.Count); + } + } + public void Execute(RefreshSeriesCommand message) { var trigger = message.Trigger; @@ -202,6 +239,7 @@ namespace NzbDrone.Core.Tv try { series = RefreshSeriesInfo(message.SeriesId.Value); + UpdateTags(series); RescanSeries(series, isNew, trigger); } catch (SeriesNotFoundException) @@ -211,6 +249,7 @@ namespace NzbDrone.Core.Tv catch (Exception e) { _logger.Error(e, "Couldn't refresh info for {0}", series); + UpdateTags(series); RescanSeries(series, isNew, trigger); throw; } @@ -238,11 +277,13 @@ namespace NzbDrone.Core.Tv _logger.Error(e, "Couldn't refresh info for {0}", seriesLocal); } + UpdateTags(series); RescanSeries(seriesLocal, false, trigger); } else { _logger.Info("Skipping refresh of series: {0}", seriesLocal.Title); + UpdateTags(series); RescanSeries(seriesLocal, false, trigger); } } diff --git a/src/Sonarr.Api.V3/AutoTagging/AutoTaggingController.cs b/src/Sonarr.Api.V3/AutoTagging/AutoTaggingController.cs new file mode 100644 index 000000000..be1c6e1b2 --- /dev/null +++ b/src/Sonarr.Api.V3/AutoTagging/AutoTaggingController.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; +using Sonarr.Http; +using Sonarr.Http.REST; +using Sonarr.Http.REST.Attributes; + +namespace Sonarr.Api.V3.AutoTagging +{ + [V3ApiController] + public class AutoTaggingController : RestController + { + private readonly IAutoTaggingService _autoTaggingService; + private readonly List _specifications; + + public AutoTaggingController(IAutoTaggingService autoTaggingService, + List specifications) + { + _autoTaggingService = autoTaggingService; + _specifications = specifications; + + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + SharedValidator.RuleFor(c => c.Name) + .Must((v, c) => !_autoTaggingService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique."); + SharedValidator.RuleFor(c => c.Tags).NotEmpty(); + SharedValidator.RuleFor(c => c.Specifications).NotEmpty(); + SharedValidator.RuleFor(c => c).Custom((autoTag, context) => + { + if (!autoTag.Specifications.Any()) + { + context.AddFailure("Must contain at least one Condition"); + } + + if (autoTag.Specifications.Any(s => s.Name.IsNullOrWhiteSpace())) + { + context.AddFailure("Condition name(s) cannot be empty or consist of only spaces"); + } + }); + } + + protected override AutoTaggingResource GetResourceById(int id) + { + return _autoTaggingService.GetById(id).ToResource(); + } + + [RestPostById] + [Consumes("application/json")] + public ActionResult Create(AutoTaggingResource autoTagResource) + { + var model = autoTagResource.ToModel(_specifications); + return Created(_autoTaggingService.Insert(model).Id); + } + + [RestPutById] + [Consumes("application/json")] + public ActionResult Update(AutoTaggingResource resource) + { + var model = resource.ToModel(_specifications); + _autoTaggingService.Update(model); + + return Accepted(model.Id); + } + + [HttpGet] + [Produces("application/json")] + public List GetAll() + { + return _autoTaggingService.All().ToResource(); + } + + [RestDeleteById] + public void DeleteFormat(int id) + { + _autoTaggingService.Delete(id); + } + + [HttpGet("schema")] + public object GetTemplates() + { + var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList(); + + return schema; + } + } +} diff --git a/src/Sonarr.Api.V3/AutoTagging/AutoTaggingResource.cs b/src/Sonarr.Api.V3/AutoTagging/AutoTaggingResource.cs new file mode 100644 index 000000000..99a930ce8 --- /dev/null +++ b/src/Sonarr.Api.V3/AutoTagging/AutoTaggingResource.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; +using Sonarr.Http.ClientSchema; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V3.AutoTagging +{ + public class AutoTaggingResource : RestResource + { + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public override int Id { get; set; } + public string Name { get; set; } + public bool RemoveTagsAutomatically { get; set; } + public HashSet Tags { get; set; } + public List Specifications { get; set; } + } + + public static class AutoTaggingResourceMapper + { + public static AutoTaggingResource ToResource(this AutoTag model) + { + return new AutoTaggingResource + { + Id = model.Id, + Name = model.Name, + RemoveTagsAutomatically = model.RemoveTagsAutomatically, + Tags = model.Tags, + Specifications = model.Specifications.Select(x => x.ToSchema()).ToList() + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(m => m.ToResource()).ToList(); + } + + public static AutoTag ToModel(this AutoTaggingResource resource, List specifications) + { + return new AutoTag + { + Id = resource.Id, + Name = resource.Name, + RemoveTagsAutomatically = resource.RemoveTagsAutomatically, + Tags = resource.Tags, + Specifications = resource.Specifications.Select(x => MapSpecification(x, specifications)).ToList() + }; + } + + private static IAutoTaggingSpecification MapSpecification(AutoTaggingSpecificationSchema resource, List specifications) + { + var matchingSpec = + specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation); + + if (matchingSpec is null) + { + throw new ArgumentException( + $"{resource.Implementation} is not a valid specification implementation"); + } + + var type = matchingSpec.GetType(); + + // Finding the exact current specification isn't possible given the dynamic nature of them and the possibility that multiple + // of the same type exist within the same format. Passing in null is safe as long as there never exists a specification that + // relies on additional privacy. + var spec = (IAutoTaggingSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type, null); + spec.Name = resource.Name; + spec.Negate = resource.Negate; + return spec; + } + } +} diff --git a/src/Sonarr.Api.V3/AutoTagging/AutoTaggingSpecificationSchema.cs b/src/Sonarr.Api.V3/AutoTagging/AutoTaggingSpecificationSchema.cs new file mode 100644 index 000000000..59c8853ab --- /dev/null +++ b/src/Sonarr.Api.V3/AutoTagging/AutoTaggingSpecificationSchema.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using NzbDrone.Core.AutoTagging.Specifications; +using Sonarr.Http.ClientSchema; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V3.AutoTagging +{ + public class AutoTaggingSpecificationSchema : RestResource + { + public string Name { get; set; } + public string Implementation { get; set; } + public string ImplementationName { get; set; } + public bool Negate { get; set; } + public bool Required { get; set; } + public List Fields { get; set; } + } + + public static class AutoTaggingSpecificationSchemaMapper + { + public static AutoTaggingSpecificationSchema ToSchema(this IAutoTaggingSpecification model) + { + return new AutoTaggingSpecificationSchema + { + Name = model.Name, + Implementation = model.GetType().Name, + ImplementationName = model.ImplementationName, + Negate = model.Negate, + Required = model.Required, + Fields = SchemaBuilder.ToSchema(model) + }; + } + } +} diff --git a/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs b/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs index ea6a7b18b..104cc31a3 100644 --- a/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs +++ b/src/Sonarr.Api.V3/Tags/TagDetailsResource.cs @@ -13,6 +13,7 @@ namespace Sonarr.Api.V3.Tags public List NotificationIds { get; set; } public List RestrictionIds { get; set; } public List IndexerIds { get; set; } + public List AutoTagIds { get; set; } public List SeriesIds { get; set; } } @@ -34,6 +35,7 @@ namespace Sonarr.Api.V3.Tags NotificationIds = model.NotificationIds, RestrictionIds = model.RestrictionIds, IndexerIds = model.IndexerIds, + AutoTagIds = model.AutoTagIds, SeriesIds = model.SeriesIds }; }