From 04e3fd84fe26d605ff4b433d72f4de9e3898665f Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 5 Dec 2022 22:58:53 -0800 Subject: [PATCH] New: Auto tagging of artists (cherry picked from commit 335fc05dd1595b6db912ebdde51ef4667963b37d) --- .../Components/Form/ProviderFieldFormGroup.js | 2 + .../Settings/Tags/AutoTagging/AutoTagging.css | 38 +++ .../Tags/AutoTagging/AutoTagging.css.d.ts | 12 + .../Settings/Tags/AutoTagging/AutoTagging.js | 136 +++++++++ .../Tags/AutoTagging/AutoTaggings.css | 21 ++ .../Tags/AutoTagging/AutoTaggings.css.d.ts | 9 + .../Settings/Tags/AutoTagging/AutoTaggings.js | 108 +++++++ .../Tags/AutoTagging/EditAutoTaggingModal.js | 50 ++++ .../EditAutoTaggingModalContent.css | 27 ++ .../EditAutoTaggingModalContent.css.d.ts | 10 + .../EditAutoTaggingModalContent.js | 269 ++++++++++++++++++ .../Specifications/AddSpecificationItem.css | 44 +++ .../AddSpecificationItem.css.d.ts | 13 + .../Specifications/AddSpecificationItem.js | 101 +++++++ .../Specifications/AddSpecificationModal.js | 25 ++ .../AddSpecificationModalContent.css | 5 + .../AddSpecificationModalContent.css.d.ts | 7 + .../AddSpecificationModalContent.js | 106 +++++++ .../AddSpecificationPresetMenuItem.js | 34 +++ .../Specifications/EditSpecificationModal.js | 36 +++ .../EditSpecificationModalContent.css | 5 + .../EditSpecificationModalContent.css.d.ts | 7 + .../EditSpecificationModalContent.js | 190 +++++++++++++ .../EditSpecificationModalContentConnector.js | 78 +++++ .../Specifications/Specification.css | 38 +++ .../Specifications/Specification.css.d.ts | 12 + .../Specifications/Specification.js | 122 ++++++++ .../Tags/Details/TagDetailsModalContent.js | 18 ++ .../TagDetailsModalContentConnector.js | 14 +- frontend/src/Settings/Tags/Tag.js | 127 ++++----- frontend/src/Settings/Tags/TagInUse.js | 34 +++ frontend/src/Settings/Tags/TagSettings.js | 2 + .../Settings/autoTaggingSpecifications.js | 193 +++++++++++++ .../Store/Actions/Settings/autoTaggings.js | 109 +++++++ frontend/src/Store/Actions/settingsActions.js | 11 +- .../createProviderSettingsSelector.js | 114 ++++---- frontend/src/Utilities/String/translate.ts | 4 + .../AutoTagging/AutoTaggingController.cs | 89 ++++++ .../AutoTagging/AutoTaggingResource.cs | 75 +++++ .../AutoTaggingSpecificationSchema.cs | 33 +++ src/Lidarr.Api.V1/Tags/TagDetailsResource.cs | 6 +- .../AutoTagging/AutoTaggingServiceFixture.cs | 125 ++++++++ .../MusicTests/RefreshArtistServiceFixture.cs | 5 + .../Annotations/FieldDefinitionAttribute.cs | 3 +- src/NzbDrone.Core/AutoTagging/AutoTag.cs | 19 ++ .../AutoTagging/AutoTaggingChanges.cs | 16 ++ .../AutoTagging/AutoTaggingRepository.cs | 17 ++ .../AutoTagging/AutoTaggingService.cs | 128 +++++++++ .../AutoTagging/SpecificationMatchesGroup.cs | 14 + .../AutoTagSpecificationBase.cs | 36 +++ .../Specifications/GenreSpecification.cs | 39 +++ .../Specifications/IAutoTagSpecification.cs | 18 ++ .../Specifications/RootFolderSpecification.cs | 38 +++ .../AutoTagSpecificationConverter.cs | 74 +++++ .../Migration/074_add_auto_tagging.cs | 18 ++ src/NzbDrone.Core/Datastore/TableMapping.cs | 7 +- .../Housekeepers/CleanupUnusedTags.cs | 2 +- src/NzbDrone.Core/Localization/Core/en.json | 26 +- .../Music/Services/RefreshArtistService.cs | 48 +++- src/NzbDrone.Core/Tags/TagDetails.cs | 2 + src/NzbDrone.Core/Tags/TagService.cs | 8 + 61 files changed, 2848 insertions(+), 129 deletions(-) create mode 100644 frontend/src/Settings/Tags/AutoTagging/AutoTagging.css create mode 100644 frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts create mode 100644 frontend/src/Settings/Tags/AutoTagging/AutoTagging.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css create mode 100644 frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts create mode 100644 frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css create mode 100644 frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts create mode 100644 frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts create mode 100644 frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js create mode 100644 frontend/src/Settings/Tags/TagInUse.js create mode 100644 frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js create mode 100644 frontend/src/Store/Actions/Settings/autoTaggings.js create mode 100644 src/Lidarr.Api.V1/AutoTagging/AutoTaggingController.cs create mode 100644 src/Lidarr.Api.V1/AutoTagging/AutoTaggingResource.cs create mode 100644 src/Lidarr.Api.V1/AutoTagging/AutoTaggingSpecificationSchema.cs create mode 100644 src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs create mode 100644 src/NzbDrone.Core/AutoTagging/AutoTag.cs create mode 100644 src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs create mode 100644 src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs create mode 100644 src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs create mode 100644 src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs create mode 100644 src/NzbDrone.Core/AutoTagging/Specifications/AutoTagSpecificationBase.cs create mode 100644 src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs create mode 100644 src/NzbDrone.Core/AutoTagging/Specifications/IAutoTagSpecification.cs create mode 100644 src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs create mode 100644 src/NzbDrone.Core/Datastore/Converters/AutoTagSpecificationConverter.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/074_add_auto_tagging.cs 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 ( + +
+
+ {name} +
+ +
+ +
+
+ + + +
+ { + specifications.map((item, index) => { + if (!item) { + return null; + } + + let kind = kinds.DEFAULT; + if (item.required) { + kind = kinds.SUCCESS; + } + if (item.negate) { + kind = kinds.DANGER; + } + + return ( + + ); + }) + } +
+ + + + +
+ ); +} + +AutoTagging.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + specifications: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteAutoTagging: PropTypes.func.isRequired, + onCloneAutoTaggingPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css new file mode 100644 index 000000000..40950bd5f --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css @@ -0,0 +1,21 @@ +.autoTaggings { + display: flex; + flex-wrap: wrap; +} + +.addAutoTagging { + composes: autoTagging from '~./AutoTagging.css'; + + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--cardCenterBackgroundColor); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.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 ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + +
+
+ ); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js new file mode 100644 index 000000000..c6f810785 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditAutoTaggingModalContent from './EditAutoTaggingModalContent'; + +export default function EditAutoTaggingModal(props) { + const { + isOpen, + onModalClose: onOriginalModalClose, + ...otherProps + } = props; + + const dispatch = useDispatch(); + const [height, setHeight] = useState('auto'); + + const onContentHeightChange = useCallback((h) => { + if (height === 'auto' || h > height) { + setHeight(h); + } + }, [height, setHeight]); + + const onModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'settings.autoTaggings' })); + onOriginalModalClose(); + }, [dispatch, onOriginalModalClose]); + + return ( + + + + ); +} + +EditAutoTaggingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css new file mode 100644 index 000000000..a197dbcd4 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css @@ -0,0 +1,27 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.rightButtons { + justify-content: flex-end; + margin-right: auto; +} + +.addSpecification { + composes: autoTagging from '~./AutoTagging.css'; + + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--cardCenterBackgroundColor); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.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 ? +
+
+ + + {translate('Name')} + + + + + + + {translate('RemoveTagsAutomatically')} + + + + + + {translate('Tags')} + + + +
+ +
+
+ { + specifications.map((tag) => { + return ( + + ); + }) + } + + +
+ +
+
+
+
+ + + + + + {/* */} + +
: + 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 ? + + + + + + + + { + presets.map((preset, index) => { + return ( + + ); + }) + } + + + : + null + } + + { + infoLink ? + : + null + } +
+
+
+ ); +} + +AddSpecificationItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string, + presets: PropTypes.arrayOf(PropTypes.object), + onSpecificationSelect: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js new file mode 100644 index 000000000..1a8c115f0 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddSpecificationModalContent from './AddSpecificationModalContent'; + +function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddSpecificationModal; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css new file mode 100644 index 000000000..d51349ea9 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css @@ -0,0 +1,5 @@ +.specifications { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.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 ( + + {name} + + ); +} + +AddSpecificationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js new file mode 100644 index 000000000..16ed4daec --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditSpecificationModalContent from './EditSpecificationModalContent'; + +function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + const dispatch = useDispatch(); + + const onWrappedModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'settings.autoTaggingSpecifications' })); + onModalClose(); + }, [onModalClose, dispatch]); + + return ( + + + + ); +} + +EditSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSpecificationModal; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.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 })} + + + +
+ { + fields && fields.some((x) => x.label === 'Regular Expression') && + +
+ +
+
+ +
+
+ +
+
+ } + + + + {translate('Name')} + + + + + + { + fields && fields.map((field) => { + return ( + + ); + }) + } + + + + {translate('Negate')} + + + + + + + + {translate('Required')} + + + + + +
+ + { + 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 ( + +
+
+ {name} +
+ + +
+ +
+ + + { + negate ? + : + null + } + + { + required ? + : + null + } +
+ + + + +
+ ); +} + +Specification.propTypes = { + id: PropTypes.number.isRequired, + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + negate: PropTypes.bool.isRequired, + required: PropTypes.bool.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteSpecification: PropTypes.func.isRequired, + onCloneSpecificationPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index 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 ? +
+ { + autoTags.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
: + 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(), }); }