diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
index 637a80c1c..9aee59776 100644
--- a/frontend/src/Components/Form/ProviderFieldFormGroup.js
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -37,6 +37,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.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts
new file mode 100644
index 000000000..b6b665429
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'autoTagging': string;
+ 'cloneButton': string;
+ 'formats': string;
+ 'name': string;
+ 'nameContainer': string;
+ 'tooltipLabel': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js
new file mode 100644
index 000000000..760273cb3
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js
@@ -0,0 +1,136 @@
+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 translate from 'Utilities/String/translate';
+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 (
+
+
+
+
+
+
+ {
+ 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.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts
new file mode 100644
index 000000000..ef3094d3b
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addAutoTagging': string;
+ 'autoTaggings': string;
+ 'center': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js
new file mode 100644
index 000000000..0d86721af
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js
@@ -0,0 +1,108 @@
+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 {
+ cloneAutoTagging,
+ deleteAutoTagging,
+ fetchAutoTaggings,
+ fetchRootFolders
+} from 'Store/Actions/settingsActions';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import sortByName from 'Utilities/Array/sortByName';
+import translate from 'Utilities/String/translate';
+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 (
+
+ );
+}
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.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts
new file mode 100644
index 000000000..1339caf02
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'addSpecification': string;
+ 'center': string;
+ 'deleteButton': string;
+ 'rightButtons': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js
new file mode 100644
index 000000000..01a5e846b
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js
@@ -0,0 +1,269 @@
+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 translate from 'Utilities/String/translate';
+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 ? translate('EditAutoTag') : translate('AddAutoTag')}
+
+
+
+
+ {
+ isFetching ?
: null
+ }
+
+ {
+ !isFetching && !!error ?
+
+ {translate('AddAutoTagError')}
+
:
+ null
+ }
+
+ {
+ !isFetching && !error && specificationsPopulated ?
+
+
+
+
+
+
+
+
+
+ {/*
*/}
+
+
:
+ null
+ }
+
+
+
+
+ {
+ id ?
+ :
+ null
+ }
+
+ {/* */}
+
+
+
+
+
+ {translate('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.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts
new file mode 100644
index 000000000..7f8a93de9
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts
@@ -0,0 +1,13 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'name': string;
+ 'overlay': string;
+ 'presetsMenu': string;
+ 'presetsMenuButton': string;
+ 'specification': string;
+ 'underlay': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
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..f6f2b134e
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js
@@ -0,0 +1,101 @@
+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 translate from 'Utilities/String/translate';
+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 ?
+
+
+
+
+ :
+ 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.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts
new file mode 100644
index 000000000..83fbf5804
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'specifications': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
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..454a2591a
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js
@@ -0,0 +1,106 @@
+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 translate from 'Utilities/String/translate';
+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 (
+
+
+ {translate('AddCondition')}
+
+
+
+ {
+ isSchemaFetching ? : null
+ }
+
+ {
+ !isSchemaFetching && !!schemaError ?
+
+ {translate('AddConditionError')}
+
:
+ null
+ }
+
+ {
+ isSchemaPopulated && !schemaError ?
+
+
+
+
+ {translate('SupportedAutoTaggingProperties')}
+
+
+
+
+ {
+ 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 (
+
+ );
+}
+
+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.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts
new file mode 100644
index 000000000..c5f0ef8a7
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deleteButton': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
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..2ab1e4a1c
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js
@@ -0,0 +1,190 @@
+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 SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+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 translate from 'Utilities/String/translate';
+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 ? translate('EditConditionImplementation', { implementationName }) : translate('AddConditionImplementation', { implementationName })}
+
+
+
+
+
+
+ {
+ id ?
+ :
+ null
+ }
+
+
+
+
+ {translate('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.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts
new file mode 100644
index 000000000..b3229d715
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'autoTagging': string;
+ 'cloneButton': string;
+ 'labels': string;
+ 'name': string;
+ 'nameContainer': string;
+ 'tooltipLabel': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
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..21977e160
--- /dev/null
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js
@@ -0,0 +1,122 @@
+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 translate from 'Utilities/String/translate';
+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 (
+
+
+
+
+
+
+ {
+ 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 4473ddfef..78372d5a3 100644
--- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js
@@ -23,6 +23,7 @@ function TagDetailsModalContent(props) {
releaseProfiles,
indexers,
downloadClients,
+ autoTags,
onModalClose,
onDeleteTagPress
} = props;
@@ -197,6 +198,22 @@ function TagDetailsModalContent(props) {
:
null
}
+
+ {
+ autoTags.length ?
+ :
+ null
+ }
@@ -232,6 +249,7 @@ TagDetailsModalContent.propTypes = {
releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
downloadClients: 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 d2342d52d..ddd70b253 100644
--- a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
+++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js
@@ -85,6 +85,14 @@ function createMatchingDownloadClientsSelector() {
);
}
+function createMatchingAutoTagsSelector() {
+ return createSelector(
+ (state, { autoTagIds }) => autoTagIds,
+ (state) => state.settings.autoTaggings.items,
+ findMatchingItems
+ );
+}
+
function createMapStateToProps() {
return createSelector(
createMatchingArtistSelector(),
@@ -94,7 +102,8 @@ function createMapStateToProps() {
createMatchingReleaseProfilesSelector(),
createMatchingIndexersSelector(),
createMatchingDownloadClientsSelector(),
- (artist, delayProfiles, importLists, notifications, releaseProfiles, indexers, downloadClients) => {
+ createMatchingAutoTagsSelector(),
+ (artist, delayProfiles, importLists, notifications, releaseProfiles, indexers, downloadClients, autoTags) => {
return {
artist,
delayProfiles,
@@ -102,7 +111,8 @@ function createMapStateToProps() {
notifications,
releaseProfiles,
indexers,
- downloadClients
+ downloadClients,
+ autoTags
};
}
);
diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js
index 9a0ff0bff..525bf5844 100644
--- a/frontend/src/Settings/Tags/Tag.js
+++ b/frontend/src/Settings/Tags/Tag.js
@@ -5,6 +5,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import TagDetailsModal from './Details/TagDetailsModal';
+import TagInUse from './TagInUse';
import styles from './Tag.css';
class Tag extends Component {
@@ -57,9 +58,10 @@ class Tag extends Component {
importListIds,
notificationIds,
restrictionIds,
- artistIds,
indexerIds,
- downloadClientIds
+ downloadClientIds,
+ autoTagIds,
+ artistIds
} = this.props;
const {
@@ -72,9 +74,10 @@ class Tag extends Component {
importListIds.length ||
notificationIds.length ||
restrictionIds.length ||
- artistIds.length ||
indexerIds.length ||
- downloadClientIds.length
+ downloadClientIds.length ||
+ autoTagIds.length ||
+ artistIds.length
);
return (
@@ -88,63 +91,56 @@ class Tag extends Component {
{
- isTagUsed &&
+ isTagUsed ?
- {
- artistIds.length ?
-
- {artistIds.length} artists
-
:
- 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} restriction{restrictionIds.length > 1 && 's'}
-
:
- null
- }
- {
- indexerIds.length ?
-
- {indexerIds.length} indexer{indexerIds.length > 1 && 's'}
-
:
- null
- }
-
- {
- downloadClientIds.length ?
-
- {downloadClientIds.length} download client{indexerIds.length > 1 && 's'}
-
:
- null
- }
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ :
+ null
}
{
@@ -164,6 +160,7 @@ class Tag extends Component {
restrictionIds={restrictionIds}
indexerIds={indexerIds}
downloadClientIds={downloadClientIds}
+ autoTagIds={autoTagIds}
isOpen={isDetailsModalOpen}
onModalClose={this.onDetailsModalClose}
onDeleteTagPress={this.onDeleteTagPress}
@@ -173,7 +170,7 @@ class Tag extends Component {
isOpen={isDeleteTagModalOpen}
kind={kinds.DANGER}
title={translate('DeleteTag')}
- message={translate('DeleteTagMessageText', [label])}
+ message={translate('DeleteTagMessageText', { label })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteTag}
onCancel={this.onDeleteTagModalClose}
@@ -190,9 +187,10 @@ Tag.propTypes = {
importListIds: PropTypes.arrayOf(PropTypes.number).isRequired,
notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
- artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
downloadClientIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
onConfirmDeleteTag: PropTypes.func.isRequired
};
@@ -201,9 +199,10 @@ Tag.defaultProps = {
importListIds: [],
notificationIds: [],
restrictionIds: [],
- artistIds: [],
indexerIds: [],
- downloadClientIds: []
+ downloadClientIds: [],
+ autoTagIds: [],
+ artistIds: []
};
export default Tag;
diff --git a/frontend/src/Settings/Tags/TagInUse.js b/frontend/src/Settings/Tags/TagInUse.js
new file mode 100644
index 000000000..9fb57d230
--- /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,
+ labelPlural,
+ count
+ } = props;
+
+ if (count === 0) {
+ return null;
+ }
+
+ if (count > 1 && labelPlural ) {
+ return (
+
+ {count} {labelPlural.toLowerCase()}
+
+ );
+ }
+
+ return (
+
+ {count} {label.toLowerCase()}
+
+ );
+}
+
+TagInUse.propTypes = {
+ label: PropTypes.string.isRequired,
+ labelPlural: PropTypes.string,
+ count: PropTypes.number.isRequired
+};
diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js
index ad2e499eb..ca8672603 100644
--- a/frontend/src/Settings/Tags/TagSettings.js
+++ b/frontend/src/Settings/Tags/TagSettings.js
@@ -3,6 +3,7 @@ import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
+import AutoTaggings from './AutoTagging/AutoTaggings';
import TagsConnector from './TagsConnector';
function TagSettings() {
@@ -14,6 +15,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 a8af15174..b787110c1 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';
@@ -26,6 +28,8 @@ import remotePathMappings from './Settings/remotePathMappings';
import rootFolders from './Settings/rootFolders';
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';
@@ -61,7 +65,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,
@@ -106,6 +111,8 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
// Action Handlers
export const actionHandlers = handleThunks({
+ ...autoTaggingSpecifications.actionHandlers,
+ ...autoTaggings.actionHandlers,
...customFormatSpecifications.actionHandlers,
...customFormats.actionHandlers,
...delayProfiles.actionHandlers,
@@ -141,6 +148,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/frontend/src/Utilities/String/translate.ts b/frontend/src/Utilities/String/translate.ts
index 36a6093d7..5e4728c5c 100644
--- a/frontend/src/Utilities/String/translate.ts
+++ b/frontend/src/Utilities/String/translate.ts
@@ -29,6 +29,10 @@ export default function translate(
) {
const translation = translations[key] || key;
+ if (!(key in translations)) {
+ console.warn(`MISSING ${key}`);
+ }
+
if (tokens) {
// Fallback to the old behaviour for translations not yet updated to use named tokens
Object.values(tokens).forEach((value, index) => {
diff --git a/src/Lidarr.Api.V1/AutoTagging/AutoTaggingController.cs b/src/Lidarr.Api.V1/AutoTagging/AutoTaggingController.cs
new file mode 100644
index 000000000..dc3a53f4f
--- /dev/null
+++ b/src/Lidarr.Api.V1/AutoTagging/AutoTaggingController.cs
@@ -0,0 +1,89 @@
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using Lidarr.Http;
+using Lidarr.Http.REST;
+using Lidarr.Http.REST.Attributes;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.AutoTagging;
+using NzbDrone.Core.AutoTagging.Specifications;
+
+namespace Lidarr.Api.V3.AutoTagging
+{
+ [V1ApiController]
+ 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");
+ }
+ });
+ }
+
+ public 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/Lidarr.Api.V1/AutoTagging/AutoTaggingResource.cs b/src/Lidarr.Api.V1/AutoTagging/AutoTaggingResource.cs
new file mode 100644
index 000000000..932b45fb7
--- /dev/null
+++ b/src/Lidarr.Api.V1/AutoTagging/AutoTaggingResource.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Serialization;
+using Lidarr.Http.ClientSchema;
+using Lidarr.Http.REST;
+using NzbDrone.Core.AutoTagging;
+using NzbDrone.Core.AutoTagging.Specifications;
+
+namespace Lidarr.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/Lidarr.Api.V1/AutoTagging/AutoTaggingSpecificationSchema.cs b/src/Lidarr.Api.V1/AutoTagging/AutoTaggingSpecificationSchema.cs
new file mode 100644
index 000000000..2b7afc543
--- /dev/null
+++ b/src/Lidarr.Api.V1/AutoTagging/AutoTaggingSpecificationSchema.cs
@@ -0,0 +1,33 @@
+using System.Collections.Generic;
+using Lidarr.Http.ClientSchema;
+using Lidarr.Http.REST;
+using NzbDrone.Core.AutoTagging.Specifications;
+
+namespace Lidarr.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/Lidarr.Api.V1/Tags/TagDetailsResource.cs b/src/Lidarr.Api.V1/Tags/TagDetailsResource.cs
index 231131486..138b91507 100644
--- a/src/Lidarr.Api.V1/Tags/TagDetailsResource.cs
+++ b/src/Lidarr.Api.V1/Tags/TagDetailsResource.cs
@@ -12,9 +12,10 @@ namespace Lidarr.Api.V1.Tags
public List ImportListIds { get; set; }
public List NotificationIds { get; set; }
public List RestrictionIds { get; set; }
- public List ArtistIds { get; set; }
public List IndexerIds { get; set; }
public List DownloadClientIds { get; set; }
+ public List AutoTagIds { get; set; }
+ public List ArtistIds { get; set; }
}
public static class TagDetailsResourceMapper
@@ -34,9 +35,10 @@ namespace Lidarr.Api.V1.Tags
ImportListIds = model.ImportListIds,
NotificationIds = model.NotificationIds,
RestrictionIds = model.RestrictionIds,
- ArtistIds = model.ArtistIds,
IndexerIds = model.IndexerIds,
DownloadClientIds = model.DownloadClientIds,
+ AutoTagIds = model.AutoTagIds,
+ ArtistIds = model.ArtistIds
};
}
diff --git a/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs b/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs
new file mode 100644
index 000000000..d86267a30
--- /dev/null
+++ b/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs
@@ -0,0 +1,125 @@
+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.Music;
+using NzbDrone.Core.Test.Framework;
+
+namespace NzbDrone.Core.Test.AutoTagging
+{
+ [TestFixture]
+ public class AutoTaggingServiceFixture : CoreTest
+ {
+ private Artist _artist;
+ private AutoTag _tag;
+
+ [SetUp]
+ public void Setup()
+ {
+ _artist = Builder.CreateNew()
+ .With(s => s.Metadata = new ArtistMetadata
+ {
+ Genres = new List { "Rock" }
+ })
+ .Build();
+
+ _tag = new AutoTag
+ {
+ Name = "Test",
+ Specifications = new List
+ {
+ new GenreSpecification
+ {
+ Name = "Genre",
+ Value = new List
+ {
+ "Rock"
+ }
+ }
+ },
+ 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(_artist);
+
+ result.TagsToAdd.Should().BeEmpty();
+ result.TagsToRemove.Should().BeEmpty();
+ }
+
+ [Test]
+ public void should_have_tags_to_add_if_artist_does_not_have_match_tag()
+ {
+ GivenAutoTags(new List { _tag });
+
+ var result = Subject.GetTagChanges(_artist);
+
+ result.TagsToAdd.Should().HaveCount(1);
+ result.TagsToAdd.Should().Contain(1);
+ result.TagsToRemove.Should().BeEmpty();
+ }
+
+ [Test]
+ public void should_not_have_tags_to_remove_if_artist_has_matching_tag_but_remove_is_false()
+ {
+ _artist.Tags = new HashSet { 1 };
+ _artist.Metadata.Value.Genres = new List { "NotComedy" };
+
+ GivenAutoTags(new List { _tag });
+
+ var result = Subject.GetTagChanges(_artist);
+
+ result.TagsToAdd.Should().BeEmpty();
+ result.TagsToRemove.Should().BeEmpty();
+ }
+
+ [Test]
+ public void should_have_tags_to_remove_if_artist_has_matching_tag_and_remove_is_true()
+ {
+ _artist.Tags = new HashSet { 1 };
+ _artist.Metadata.Value.Genres = new List { "NotComedy" };
+
+ _tag.RemoveTagsAutomatically = true;
+
+ GivenAutoTags(new List { _tag });
+
+ var result = Subject.GetTagChanges(_artist);
+
+ result.TagsToAdd.Should().BeEmpty();
+ result.TagsToRemove.Should().HaveCount(1);
+ result.TagsToRemove.Should().Contain(1);
+ }
+
+ [Test]
+ public void should_match_if_specification_is_negated()
+ {
+ _artist.Metadata.Value.Genres = new List { "NotComedy" };
+
+ _tag.Specifications.First().Negate = true;
+
+ GivenAutoTags(new List { _tag });
+
+ var result = Subject.GetTagChanges(_artist);
+
+ result.TagsToAdd.Should().HaveCount(1);
+ result.TagsToAdd.Should().Contain(1);
+ result.TagsToRemove.Should().BeEmpty();
+ }
+ }
+}
diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs
index b89f4910e..7e6749ffb 100644
--- a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs
+++ b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs
@@ -3,6 +3,7 @@ using FizzWare.NBuilder;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
+using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.History;
using NzbDrone.Core.ImportLists.Exclusions;
@@ -78,6 +79,10 @@ namespace NzbDrone.Core.Test.MusicTests
Mocker.GetMock()
.Setup(x => x.ShouldMonitorNewAlbum(It.IsAny(), It.IsAny>(), It.IsAny()))
.Returns(true);
+
+ Mocker.GetMock()
+ .Setup(s => s.GetTagChanges(_artist))
+ .Returns(new AutoTaggingChanges());
}
private void GivenNewArtistInfo(Artist artist)
diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
index 3e0a97b28..f20b02f83 100644
--- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
+++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
@@ -66,7 +66,8 @@ namespace NzbDrone.Core.Annotations
OAuth,
Device,
Playlist,
- 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..14bcb1922
--- /dev/null
+++ b/src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs
@@ -0,0 +1,16 @@
+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..aaa01e2a8
--- /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.Music;
+using NzbDrone.Core.RootFolders;
+
+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(Artist artist);
+ }
+
+ 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(Artist artist)
+ {
+ var autoTags = All();
+ var changes = new AutoTaggingChanges();
+
+ if (autoTags.Empty())
+ {
+ return changes;
+ }
+
+ // Set the root folder path on the series
+ artist.RootFolderPath = _rootFolderService.GetBestRootFolderPath(artist.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(artist))
+ })
+ .ToList();
+
+ var allMatch = specificationMatches.All(x => x.DidMatch);
+ var tags = autoTag.Tags;
+
+ if (allMatch)
+ {
+ foreach (var tag in tags)
+ {
+ if (!artist.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..eca00e8c0
--- /dev/null
+++ b/src/NzbDrone.Core/AutoTagging/Specifications/AutoTagSpecificationBase.cs
@@ -0,0 +1,36 @@
+using NzbDrone.Core.Music;
+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(Artist artist)
+ {
+ var match = IsSatisfiedByWithoutNegate(artist);
+
+ if (Negate)
+ {
+ match = !match;
+ }
+
+ return match;
+ }
+
+ protected abstract bool IsSatisfiedByWithoutNegate(Artist artist);
+ }
+}
diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs
new file mode 100644
index 000000000..9d8309492
--- /dev/null
+++ b/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Annotations;
+using NzbDrone.Core.Music;
+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(Artist artist)
+ {
+ return artist?.Metadata?.Value?.Genres.Any(genre => Value.ContainsIgnoreCase(genre)) ?? false;
+ }
+
+ 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..33c5a167f
--- /dev/null
+++ b/src/NzbDrone.Core/AutoTagging/Specifications/IAutoTagSpecification.cs
@@ -0,0 +1,18 @@
+using NzbDrone.Core.Music;
+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(Artist artist);
+ }
+}
diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs
new file mode 100644
index 000000000..d3882ae6a
--- /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.Music;
+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(Artist artist)
+ {
+ return artist.RootFolderPath.PathEquals(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..7d61f23f3
--- /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}, Lidarr.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/074_add_auto_tagging.cs b/src/NzbDrone.Core/Datastore/Migration/074_add_auto_tagging.cs
new file mode 100644
index 000000000..a5a660210
--- /dev/null
+++ b/src/NzbDrone.Core/Datastore/Migration/074_add_auto_tagging.cs
@@ -0,0 +1,18 @@
+using FluentMigrator;
+using NzbDrone.Core.Datastore.Migration.Framework;
+
+namespace NzbDrone.Core.Datastore.Migration
+{
+ [Migration(074)]
+ 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 4049c3fc0..0ea48c00b 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;
@@ -199,10 +200,13 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity("NotificationStatus").RegisterModel();
Mapper.Entity("CustomFilters").RegisterModel();
- Mapper.Entity("ImportListExclusions").RegisterModel();
+
Mapper.Entity("DownloadHistory").RegisterModel();
Mapper.Entity("UpdateHistory").RegisterModel();
+ Mapper.Entity("ImportListExclusions").RegisterModel();
+
+ Mapper.Entity("AutoTagging").RegisterModel();
}
private static void RegisterMappers()
@@ -217,6 +221,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 4379d5a90..43e46111c 100644
--- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs
+++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs
@@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers
public void Clean()
{
using var mapper = _database.OpenConnection();
- var usedTags = new[] { "Artists", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "DownloadClients" }
+ var usedTags = new[] { "Artists", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging", "DownloadClients" }
.SelectMany(v => GetUsedTags(v, mapper))
.Distinct()
.ToArray();
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 2f13bafaa..3db7c0181 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -8,6 +8,10 @@
"Actions": "Actions",
"Activity": "Activity",
"Add": "Add",
+ "AddAutoTag": "Add Auto Tag",
+ "AddAutoTagError": "Unable to add a new auto tag, please try again.",
+ "AddCondition": "Add Condition",
+ "AddConditionError": "Unable to add a new condition, please try again.",
"AddConditionImplementation": "Add Condition - {implementationName}",
"AddConnection": "Add Connection",
"AddConnectionImplementation": "Add Connection - {implementationName}",
@@ -127,6 +131,10 @@
"Auto": "Auto",
"AutoAdd": "Auto Add",
"AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release",
+ "AutoTagging": "Auto Tagging",
+ "AutoTaggingLoadError": "Unable to load auto tagging",
+ "AutoTaggingNegateHelpText": "If checked, the auto tagging rule will not apply if this {implementationName} condition matches.",
+ "AutoTaggingRequiredHelpText": "This {implementationName} condition must match for the auto tagging rule to apply. Otherwise a single {implementationName} match is sufficient.",
"Automatic": "Automatic",
"AutomaticAdd": "Automatic Add",
"AutomaticUpdatesDisabledDocker": "Automatic updates are not directly supported when using the Docker update mechanism. You will need to update the container image outside of {appName} or use a script",
@@ -174,6 +182,7 @@
"ClickToChangeReleaseGroup": "Click to change release group",
"ClientPriority": "Client Priority",
"Clone": "Clone",
+ "CloneAutoTag": "Clone Auto Tag",
"CloneCondition": "Clone Condition",
"CloneCustomFormat": "Clone Custom Format",
"CloneIndexer": "Clone Indexer",
@@ -186,9 +195,11 @@
"CombineWithExistingFiles": "Combine With Existing Files",
"CompletedDownloadHandling": "Completed Download Handling",
"Component": "Component",
+ "ConditionUsingRegularExpressions": "This condition matches using Regular Expressions. Note that the characters `\\^$.|?*+()[{` have special meanings and need escaping with a `\\`",
"Conditions": "Conditions",
"Connect": "Connect",
"ConnectSettings": "Connect Settings",
+ "Connection": "Connection",
"ConnectionLost": "Connection Lost",
"ConnectionLostReconnect": "{appName} will try to connect automatically, or you can click reload below.",
"ConnectionLostToBackend": "{appName} has lost its connection to the backend and will need to be reloaded to restore functionality.",
@@ -246,6 +257,8 @@
"DeleteArtistFolderHelpText": "Delete the artist folder and its contents",
"DeleteArtistFolders": "Delete Artist Folders",
"DeleteArtistFoldersHelpText": "Delete the artist folders and all their contents",
+ "DeleteAutoTag": "Delete Auto Tag",
+ "DeleteAutoTagHelpText": "Are you sure you want to delete the auto tag '{name}'?",
"DeleteBackup": "Delete Backup",
"DeleteBackupMessageText": "Are you sure you want to delete the backup '{name}'?",
"DeleteCondition": "Delete Condition",
@@ -289,8 +302,10 @@
"DeleteSelectedIndexersMessageText": "Are you sure you want to delete {count} selected indexer(s)?",
"DeleteSelectedTrackFiles": "Delete Selected Track Files",
"DeleteSelectedTrackFilesMessageText": "Are you sure you want to delete the selected track files?",
+ "DeleteSpecification": "Delete Specification",
+ "DeleteSpecificationHelpText": "Are you sure you want to delete specification '{name}'?",
"DeleteTag": "Delete Tag",
- "DeleteTagMessageText": "Are you sure you want to delete the tag '{0}'?",
+ "DeleteTagMessageText": "Are you sure you want to delete the tag '{label}'?",
"DeleteTrackFile": "Delete Track File",
"DeleteTrackFileMessageText": "Are you sure you want to delete {0}?",
"Deleted": "Deleted",
@@ -336,6 +351,7 @@
"Duration": "Duration",
"Edit": "Edit",
"EditArtist": "Edit Artist",
+ "EditAutoTag": "Edit Auto Tag",
"EditConditionImplementation": "Edit Condition - {implementationName}",
"EditConnectionImplementation": "Edit Connection - {implementationName}",
"EditDelayProfile": "Edit Delay Profile",
@@ -471,6 +487,7 @@
"ImportFailed": "Import Failed",
"ImportFailedInterp": "Import failed: {0}",
"ImportFailures": "Import failures",
+ "ImportList": "Import List",
"ImportListExclusions": "Import List Exclusions",
"ImportListRootFolderMissingRootHealthCheckMessage": "Missing root folder for import list(s): {0}",
"ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Multiple root folders are missing for import lists: {0}",
@@ -638,6 +655,7 @@
"NETCore": ".NET",
"Name": "Name",
"NamingSettings": "Naming Settings",
+ "Negate": "Negate",
"NegateHelpText": "If checked, the custom format will not apply if this {0} condition matches.",
"Negated": "Negated",
"Never": "Never",
@@ -782,10 +800,13 @@
"RefreshArtist": "Refresh Artist",
"RefreshInformationAndScanDisk": "Refresh information and scan disk",
"RefreshScan": "Refresh & Scan",
+ "RegularExpressionsCanBeTested": "Regular expressions can be tested [here](http://regexstorm.net/tester).",
+ "RegularExpressionsTutorialLink": "More details on regular expressions can be found [here](https://www.regular-expressions.info/tutorial.html).",
"RejectionCount": "Rejection Count",
"Release": " Release",
"ReleaseDate": "Release Date",
"ReleaseGroup": "Release Group",
+ "ReleaseProfile": "Release Profile",
"ReleaseProfiles": "Release Profiles",
"ReleaseRejected": "Release Rejected",
"ReleaseStatuses": "Release Statuses",
@@ -833,6 +854,8 @@
"RemoveSelectedItemsQueueMessageText": "Are you sure you want to remove {0} items from the queue?",
"RemoveTagExistingTag": "Existing tag",
"RemoveTagRemovingTag": "Removing tag",
+ "RemoveTagsAutomatically": "Remove Tags Automatically",
+ "RemoveTagsAutomaticallyHelpText": "Remove tags automatically if conditions are not met",
"RemovedFromTaskQueue": "Removed from task queue",
"RemovingTag": "Removing tag",
"RenameFiles": "Rename Files",
@@ -987,6 +1010,7 @@
"SuccessMyWorkIsDoneNoFilesToRename": "Success! My work is done, no files to rename.",
"SuccessMyWorkIsDoneNoFilesToRetag": "Success! My work is done, no files to retag.",
"SuggestTranslationChange": "Suggest translation change",
+ "SupportedAutoTaggingProperties": "{appName} supports the follow properties for auto tagging rules",
"SupportsRssvalueRSSIsNotSupportedWithThisIndexer": "RSS is not supported with this indexer",
"SupportsSearchvalueSearchIsNotSupportedWithThisIndexer": "Search is not supported with this indexer",
"SupportsSearchvalueWillBeUsedWhenAutomaticSearchesArePerformedViaTheUIOrByLidarr": "Will be used when automatic searches are performed via the UI or by Lidarr",
diff --git a/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs b/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs
index e30bc96ae..48223ecf8 100644
--- a/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs
+++ b/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs
@@ -6,6 +6,7 @@ using NLog;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
+using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.History;
@@ -37,6 +38,7 @@ namespace NzbDrone.Core.Music
private readonly ICheckIfArtistShouldBeRefreshed _checkIfArtistShouldBeRefreshed;
private readonly IMonitorNewAlbumService _monitorNewAlbumService;
private readonly IConfigService _configService;
+ private readonly IAutoTaggingService _autoTaggingService;
private readonly IImportListExclusionService _importListExclusionService;
private readonly Logger _logger;
@@ -53,6 +55,7 @@ namespace NzbDrone.Core.Music
ICheckIfArtistShouldBeRefreshed checkIfArtistShouldBeRefreshed,
IMonitorNewAlbumService monitorNewAlbumService,
IConfigService configService,
+ IAutoTaggingService autoTaggingService,
IImportListExclusionService importListExclusionService,
Logger logger)
: base(logger, artistMetadataService)
@@ -69,6 +72,7 @@ namespace NzbDrone.Core.Music
_checkIfArtistShouldBeRefreshed = checkIfArtistShouldBeRefreshed;
_monitorNewAlbumService = monitorNewAlbumService;
_configService = configService;
+ _autoTaggingService = autoTaggingService;
_importListExclusionService = importListExclusionService;
_logger = logger;
}
@@ -277,7 +281,7 @@ namespace NzbDrone.Core.Music
_eventAggregator.PublishEvent(new AlbumInfoRefreshedEvent(entity, newChildren, updateChildren, removedChildren));
}
- private void Rescan(List artists, bool isNew, CommandTrigger trigger, bool infoUpdated)
+ private void RescanArtists(List artists, bool isNew, CommandTrigger trigger, bool infoUpdated)
{
var rescanAfterRefresh = _configService.RescanAfterRefresh;
var shouldRescan = true;
@@ -332,14 +336,49 @@ namespace NzbDrone.Core.Music
try
{
updated |= RefreshEntityInfo(artist, null, true, false, null);
+ UpdateTags(artist);
}
catch (Exception e)
{
_logger.Error(e, "Couldn't refresh info for {0}", artist);
+ UpdateTags(artist);
}
}
- Rescan(artists, isNew, trigger, updated);
+ RescanArtists(artists, isNew, trigger, updated);
+ }
+
+ private void UpdateTags(Artist artist)
+ {
+ _logger.Trace("Updating tags for {0}", artist);
+
+ var tagsAdded = new HashSet();
+ var tagsRemoved = new HashSet();
+ var changes = _autoTaggingService.GetTagChanges(artist);
+
+ foreach (var tag in changes.TagsToRemove)
+ {
+ if (artist.Tags.Contains(tag))
+ {
+ artist.Tags.Remove(tag);
+ tagsRemoved.Add(tag);
+ }
+ }
+
+ foreach (var tag in changes.TagsToAdd)
+ {
+ if (!artist.Tags.Contains(tag))
+ {
+ artist.Tags.Add(tag);
+ tagsAdded.Add(tag);
+ }
+ }
+
+ if (tagsAdded.Any() || tagsRemoved.Any())
+ {
+ _artistService.UpdateArtist(artist);
+ _logger.Debug("Updated tags for '{0}'. Added: {1}, Removed: {2}", artist.Name, tagsAdded.Count, tagsRemoved.Count);
+ }
}
public void Execute(BulkRefreshArtistCommand message)
@@ -385,14 +424,17 @@ namespace NzbDrone.Core.Music
{
_logger.Error(e, "Couldn't refresh info for {0}", artist);
}
+
+ UpdateTags(artist);
}
else
{
_logger.Info("Skipping refresh of artist: {0}", artist.Name);
+ UpdateTags(artist);
}
}
- Rescan(artists, isNew, trigger, updated);
+ RescanArtists(artists, isNew, trigger, updated);
}
}
}
diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs
index d1bb85686..bec45f60f 100644
--- a/src/NzbDrone.Core/Tags/TagDetails.cs
+++ b/src/NzbDrone.Core/Tags/TagDetails.cs
@@ -14,6 +14,7 @@ namespace NzbDrone.Core.Tags
public List ImportListIds { get; set; }
public List RootFolderIds { get; set; }
public List IndexerIds { get; set; }
+ public List AutoTagIds { get; set; }
public List DownloadClientIds { get; set; }
public bool InUse => ArtistIds.Any() ||
@@ -23,6 +24,7 @@ namespace NzbDrone.Core.Tags
ImportListIds.Any() ||
RootFolderIds.Any() ||
IndexerIds.Any() ||
+ AutoTagIds.Any() ||
DownloadClientIds.Any();
}
}
diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs
index 5fec388ab..f97881a29 100644
--- a/src/NzbDrone.Core/Tags/TagService.cs
+++ b/src/NzbDrone.Core/Tags/TagService.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
+using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Download;
using NzbDrone.Core.ImportLists;
@@ -36,6 +37,7 @@ namespace NzbDrone.Core.Tags
private readonly IArtistService _artistService;
private readonly IRootFolderService _rootFolderService;
private readonly IIndexerFactory _indexerService;
+ private readonly IAutoTaggingService _autoTaggingService;
private readonly IDownloadClientFactory _downloadClientFactory;
public TagService(ITagRepository repo,
@@ -47,6 +49,7 @@ namespace NzbDrone.Core.Tags
IArtistService artistService,
IRootFolderService rootFolderService,
IIndexerFactory indexerService,
+ IAutoTaggingService autoTaggingService,
IDownloadClientFactory downloadClientFactory)
{
_repo = repo;
@@ -58,6 +61,7 @@ namespace NzbDrone.Core.Tags
_artistService = artistService;
_rootFolderService = rootFolderService;
_indexerService = indexerService;
+ _autoTaggingService = autoTaggingService;
_downloadClientFactory = downloadClientFactory;
}
@@ -88,6 +92,7 @@ namespace NzbDrone.Core.Tags
var artist = _artistService.AllForTag(tagId);
var rootFolders = _rootFolderService.AllForTag(tagId);
var indexers = _indexerService.AllForTag(tagId);
+ var autoTags = _autoTaggingService.AllForTag(tagId);
var downloadClients = _downloadClientFactory.AllForTag(tagId);
return new TagDetails
@@ -101,6 +106,7 @@ namespace NzbDrone.Core.Tags
ArtistIds = artist.Select(c => c.Id).ToList(),
RootFolderIds = rootFolders.Select(c => c.Id).ToList(),
IndexerIds = indexers.Select(c => c.Id).ToList(),
+ AutoTagIds = autoTags.Select(c => c.Id).ToList(),
DownloadClientIds = downloadClients.Select(c => c.Id).ToList()
};
}
@@ -115,6 +121,7 @@ namespace NzbDrone.Core.Tags
var artists = _artistService.GetAllArtistsTags();
var rootFolders = _rootFolderService.All();
var indexers = _indexerService.All();
+ var autotags = _autoTaggingService.All();
var downloadClients = _downloadClientFactory.All();
var details = new List();
@@ -132,6 +139,7 @@ namespace NzbDrone.Core.Tags
ArtistIds = artists.Where(c => c.Value.Contains(tag.Id)).Select(c => c.Key).ToList(),
RootFolderIds = rootFolders.Where(c => c.DefaultTags.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(),
DownloadClientIds = downloadClients.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
});
}