New: Auto tagging of artists

(cherry picked from commit 335fc05dd1595b6db912ebdde51ef4667963b37d)
pull/4264/head
Mark McDowall 1 year ago committed by Bogdan
parent d5ac008747
commit 362bd42cb8

@ -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;
}

@ -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;
}

@ -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;

@ -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 (
<Card
className={styles.autoTagging}
overlayContent={true}
onPress={onEditPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<div>
<IconButton
className={styles.cloneButton}
title={translate('CloneAutoTag')}
name={icons.CLONE}
onPress={onClonePress}
/>
</div>
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<div>
{
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 (
<Label
key={index}
kind={kind}
>
{item.name}
</Label>
);
})
}
</div>
<EditAutoTaggingModal
id={id}
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onDeleteAutoTaggingPress={onDeletePress}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteAutoTag')}
message={translate('DeleteAutoTagHelpText', { name })}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</Card>
);
}
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
};

@ -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);
}

@ -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;

@ -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 (
<FieldSet legend={translate('AutoTagging')}>
<PageSectionContent
errorMessage={translate('AutoTaggingLoadError')}
error={error}
isFetching={isFetching}
isPopulated={isPopulated}
>
<div className={styles.autoTaggings}>
{
items.map((item) => {
return (
<AutoTagging
key={item.id}
{...item}
isDeleting={isDeleting}
tagList={tagList}
onConfirmDeleteAutoTagging={onConfirmDelete}
onCloneAutoTaggingPress={onClonePress}
/>
);
})
}
<Card
className={styles.addAutoTagging}
onPress={onEditPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
<EditAutoTaggingModal
isOpen={isEditModalOpen}
tagsFromId={tagsFromId}
onModalClose={onEditModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}

@ -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 (
<Modal
style={{ height: height === 'auto' ? 'auto': `${height}px` }}
isOpen={isOpen}
size={sizes.LARGE}
onModalClose={onModalClose}
>
<EditAutoTaggingModalContent
{...otherProps}
onContentHeightChange={onContentHeightChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditAutoTaggingModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -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);
}

@ -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;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? translate('EditAutoTag') : translate('AddAutoTag')}
</ModalHeader>
<ModalBody>
<div>
{
isFetching ? <LoadingIndicator />: null
}
{
!isFetching && !!error ?
<div>
{translate('AddAutoTagError')}
</div> :
null
}
{
!isFetching && !error && specificationsPopulated ?
<div>
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>
{translate('Name')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveTagsAutomatically')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeTagsAutomatically"
helpText={translate('RemoveTagsAutomaticallyHelpText')}
{...removeTagsAutomatically}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
<FieldSet legend={translate('Conditions')}>
<div className={styles.autoTaggings}>
{
specifications.map((tag) => {
return (
<Specification
key={tag.id}
{...tag}
onCloneSpecificationPress={onCloneSpecificationPress}
onConfirmDeleteSpecification={onConfirmDeleteSpecification}
/>
);
})
}
<Card
className={styles.addSpecification}
onPress={onAddSpecificationPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
</FieldSet>
<AddSpecificationModal
isOpen={isAddSpecificationModalOpen}
onModalClose={onAddSpecificationModalClose}
/>
<EditSpecificationModal
isOpen={isEditSpecificationModalOpen}
onModalClose={onEditSpecificationModalClose}
/>
{/* <ImportAutoTaggingModal
isOpen={isImportAutoTaggingModalOpen}
onModalClose={onImportAutoTaggingModalClose}
/> */}
</div> :
null
}
</div>
</ModalBody>
<ModalFooter>
<div className={styles.rightButtons}>
{
id ?
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteAutoTaggingPress}
>
{translate('Delete')}
</Button> :
null
}
{/* <Button
className={styles.deleteButton}
onPress={onImportPress}
>
Import
</Button> */}
</div>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditAutoTaggingModalContent.propTypes = {
id: PropTypes.number,
tagsFromId: PropTypes.number,
onModalClose: PropTypes.func.isRequired,
onDeleteAutoTaggingPress: PropTypes.func
};

@ -0,0 +1,44 @@
.specification {
composes: card from '~Components/Card.css';
position: relative;
width: 300px;
height: 100px;
}
.underlay {
@add-mixin cover;
}
.overlay {
@add-mixin linkOverlay;
padding: 10px;
}
.name {
text-align: center;
font-weight: lighter;
font-size: 24px;
}
.actions {
margin-top: 20px;
text-align: right;
}
.presetsMenu {
composes: menu from '~Components/Menu/Menu.css';
display: inline-block;
margin: 0 5px;
}
.presetsMenuButton {
composes: button from '~Components/Link/Button.css';
&::after {
margin-left: 5px;
content: '\25BE';
}
}

@ -0,0 +1,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;

@ -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 (
<div
className={styles.specification}
>
<Link
className={styles.underlay}
onPress={onWrappedSpecificationSelect}
/>
<div className={styles.overlay}>
<div className={styles.name}>
{implementationName}
</div>
<div className={styles.actions}>
{
hasPresets ?
<span>
<Button
size={sizes.SMALL}
onPress={onWrappedSpecificationSelect}
>
{translate('Custom')}
</Button>
<Menu className={styles.presetsMenu}>
<Button
className={styles.presetsMenuButton}
size={sizes.SMALL}
>
{translate('Presets')}
</Button>
<MenuContent>
{
presets.map((preset, index) => {
return (
<AddSpecificationPresetMenuItem
key={index}
name={preset.name}
implementation={implementation}
onPress={onWrappedSpecificationSelect}
/>
);
})
}
</MenuContent>
</Menu>
</span> :
null
}
{
infoLink ?
<Button
to={infoLink}
size={sizes.SMALL}
>
{translate('MoreInfo')}
</Button> :
null
}
</div>
</div>
</div>
);
}
AddSpecificationItem.propTypes = {
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
infoLink: PropTypes.string,
presets: PropTypes.arrayOf(PropTypes.object),
onSpecificationSelect: PropTypes.func.isRequired
};

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddSpecificationModalContent
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddSpecificationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddSpecificationModal;

@ -0,0 +1,5 @@
.specifications {
display: flex;
justify-content: center;
flex-wrap: wrap;
}

@ -0,0 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'specifications': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AddCondition')}
</ModalHeader>
<ModalBody>
{
isSchemaFetching ? <LoadingIndicator /> : null
}
{
!isSchemaFetching && !!schemaError ?
<div>
{translate('AddConditionError')}
</div> :
null
}
{
isSchemaPopulated && !schemaError ?
<div>
<Alert kind={kinds.INFO}>
<div>
{translate('SupportedAutoTaggingProperties')}
</div>
</Alert>
<div className={styles.specifications}>
{
schema.map((specification) => {
return (
<AddSpecificationItem
key={specification.implementation}
{...specification}
onSpecificationSelect={onSpecificationSelect}
/>
);
})
}
</div>
</div> :
null
}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
AddSpecificationModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired
};

@ -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 (
<MenuItem
{...otherProps}
onPress={onWrappedPress}
>
{name}
</MenuItem>
);
}
AddSpecificationPresetMenuItem.propTypes = {
name: PropTypes.string.isRequired,
implementation: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};

@ -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 (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditSpecificationModalContent
{...otherProps}
onModalClose={onWrappedModalClose}
/>
</Modal>
);
}
EditSpecificationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditSpecificationModal;

@ -0,0 +1,5 @@
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: auto;
}

@ -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;

@ -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 (
<ModalContent onModalClose={onCancelPress}>
<ModalHeader>
{id ? translate('EditConditionImplementation', { implementationName }) : translate('AddConditionImplementation', { implementationName })}
</ModalHeader>
<ModalBody>
<Form
{...otherFormProps}
>
{
fields && fields.some((x) => x.label === 'Regular Expression') &&
<Alert kind={kinds.INFO}>
<div>
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
</div>
</Alert>
}
<FormGroup>
<FormLabel>
{translate('Name')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
{
fields && fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="specifications"
providerData={item}
{...field}
onChange={onFieldChange}
/>
);
})
}
<FormGroup>
<FormLabel>
{translate('Negate')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="negate"
{...negate}
helpText={translate('AutoTaggingNegateHelpText', { implementationName })}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Required')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="required"
{...required}
helpText={translate('AutoTaggingRequiredHelpText', { implementationName })}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
{
id ?
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteSpecificationPress}
>
{translate('Delete')}
</Button> :
null
}
<Button
onPress={onCancelPress}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={false}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditSpecificationModalContent.propTypes = {
id: PropTypes.number,
onDeleteSpecificationPress: PropTypes.func,
onModalClose: PropTypes.func.isRequired
};
export default EditSpecificationModalContent;

@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { 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 (
<EditSpecificationModalContent
{...this.props}
onCancelPress={this.onCancelPress}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
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);

@ -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;
}

@ -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;

@ -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 (
<Card
className={styles.autoTagging}
overlayContent={true}
onPress={onEditPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<IconButton
className={styles.cloneButton}
title={translate('Clone')}
name={icons.CLONE}
onPress={onClonePress}
/>
</div>
<div className={styles.labels}>
<Label kind={kinds.DEFAULT}>
{implementationName}
</Label>
{
negate ?
<Label kind={kinds.DANGER}>
{translate('Negated')}
</Label> :
null
}
{
required ?
<Label kind={kinds.SUCCESS}>
{translate('Required')}
</Label> :
null
}
</div>
<EditSpecificationModal
id={id}
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onDeleteSpecificationPress={onDeletePress}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSpecification')}
message={translate('DeleteSpecificationHelpText', { name })}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</Card>
);
}
Specification.propTypes = {
id: PropTypes.number.isRequired,
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
negate: PropTypes.bool.isRequired,
required: PropTypes.bool.isRequired,
fields: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteSpecification: PropTypes.func.isRequired,
onCloneSpecificationPress: PropTypes.func.isRequired
};

@ -23,6 +23,7 @@ function TagDetailsModalContent(props) {
releaseProfiles,
indexers,
downloadClients,
autoTags,
onModalClose,
onDeleteTagPress
} = props;
@ -197,6 +198,22 @@ function TagDetailsModalContent(props) {
</FieldSet> :
null
}
{
autoTags.length ?
<FieldSet legend={translate('AutoTagging')}>
{
autoTags.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
</ModalBody>
<ModalFooter>
@ -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
};

@ -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
};
}
);

@ -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 {
</div>
{
isTagUsed &&
isTagUsed ?
<div>
{
artistIds.length ?
<div>
{artistIds.length} artists
</div> :
null
}
{
delayProfileIds.length ?
<div>
{delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'}
</div> :
null
}
{
importListIds.length ?
<div>
{importListIds.length} import list{importListIds.length > 1 && 's'}
</div> :
null
}
{
notificationIds.length ?
<div>
{notificationIds.length} connection{notificationIds.length > 1 && 's'}
</div> :
null
}
{
restrictionIds.length ?
<div>
{restrictionIds.length} restriction{restrictionIds.length > 1 && 's'}
</div> :
null
}
{
indexerIds.length ?
<div>
{indexerIds.length} indexer{indexerIds.length > 1 && 's'}
</div> :
null
}
{
downloadClientIds.length ?
<div>
{downloadClientIds.length} download client{indexerIds.length > 1 && 's'}
</div> :
null
}
</div>
<TagInUse
label={translate('Artist')}
labelPlural={translate('Artists')}
count={artistIds.length}
/>
<TagInUse
label={translate('DelayProfile')}
labelPlural={translate('DelayProfiles')}
count={delayProfileIds.length}
/>
<TagInUse
label={translate('ImportList')}
labelPlural={translate('ImportLists')}
count={importListIds.length}
/>
<TagInUse
label={translate('Connection')}
labelPlural={translate('Connections')}
count={notificationIds.length}
/>
<TagInUse
label={translate('ReleaseProfile')}
labelPlural={translate('ReleaseProfiles')}
count={restrictionIds.length}
/>
<TagInUse
label={translate('Indexer')}
labelPlural={translate('Indexers')}
count={indexerIds.length}
/>
<TagInUse
label={translate('DownloadClient')}
labelPlural={translate('DownloadClients')}
count={downloadClientIds.length}
/>
<TagInUse
label={translate('AutoTagging')}
count={autoTagIds.length}
/>
</div> :
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;

@ -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 (
<div>
{count} {labelPlural.toLowerCase()}
</div>
);
}
return (
<div>
{count} {label.toLowerCase()}
</div>
);
}
TagInUse.propTypes = {
label: PropTypes.string.isRequired,
labelPlural: PropTypes.string,
count: PropTypes.number.isRequired
};

@ -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() {
<PageContentBody>
<TagsConnector />
<AutoTaggings />
</PageContentBody>
</PageContent>
);

@ -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: []
})
}
};

@ -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);
}
}
};

@ -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,

@ -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;

@ -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) => {

@ -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<AutoTaggingResource>
{
private readonly IAutoTaggingService _autoTaggingService;
private readonly List<IAutoTaggingSpecification> _specifications;
public AutoTaggingController(IAutoTaggingService autoTaggingService,
List<IAutoTaggingSpecification> 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<AutoTaggingResource> Create(AutoTaggingResource autoTagResource)
{
var model = autoTagResource.ToModel(_specifications);
return Created(_autoTaggingService.Insert(model).Id);
}
[RestPutById]
[Consumes("application/json")]
public ActionResult<AutoTaggingResource> Update(AutoTaggingResource resource)
{
var model = resource.ToModel(_specifications);
_autoTaggingService.Update(model);
return Accepted(model.Id);
}
[HttpGet]
[Produces("application/json")]
public List<AutoTaggingResource> 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;
}
}
}

@ -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<int> Tags { get; set; }
public List<AutoTaggingSpecificationSchema> 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<AutoTaggingResource> ToResource(this IEnumerable<AutoTag> models)
{
return models.Select(m => m.ToResource()).ToList();
}
public static AutoTag ToModel(this AutoTaggingResource resource, List<IAutoTaggingSpecification> 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<IAutoTaggingSpecification> 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;
}
}
}

@ -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<Field> 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)
};
}
}
}

@ -12,9 +12,10 @@ namespace Lidarr.Api.V1.Tags
public List<int> ImportListIds { get; set; }
public List<int> NotificationIds { get; set; }
public List<int> RestrictionIds { get; set; }
public List<int> ArtistIds { get; set; }
public List<int> IndexerIds { get; set; }
public List<int> DownloadClientIds { get; set; }
public List<int> AutoTagIds { get; set; }
public List<int> 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
};
}

@ -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<AutoTaggingService>
{
private Artist _artist;
private AutoTag _tag;
[SetUp]
public void Setup()
{
_artist = Builder<Artist>.CreateNew()
.With(s => s.Metadata = new ArtistMetadata
{
Genres = new List<string> { "Rock" }
})
.Build();
_tag = new AutoTag
{
Name = "Test",
Specifications = new List<IAutoTaggingSpecification>
{
new GenreSpecification
{
Name = "Genre",
Value = new List<string>
{
"Rock"
}
}
},
Tags = new HashSet<int> { 1 },
RemoveTagsAutomatically = false
};
}
private void GivenAutoTags(List<AutoTag> autoTags)
{
Mocker.GetMock<IAutoTaggingRepository>()
.Setup(s => s.All())
.Returns(autoTags);
}
[Test]
public void should_not_have_changes_if_there_are_no_auto_tags()
{
GivenAutoTags(new List<AutoTag>());
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<AutoTag> { _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<int> { 1 };
_artist.Metadata.Value.Genres = new List<string> { "NotComedy" };
GivenAutoTags(new List<AutoTag> { _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<int> { 1 };
_artist.Metadata.Value.Genres = new List<string> { "NotComedy" };
_tag.RemoveTagsAutomatically = true;
GivenAutoTags(new List<AutoTag> { _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<string> { "NotComedy" };
_tag.Specifications.First().Negate = true;
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_artist);
result.TagsToAdd.Should().HaveCount(1);
result.TagsToAdd.Should().Contain(1);
result.TagsToRemove.Should().BeEmpty();
}
}
}

@ -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<IMonitorNewAlbumService>()
.Setup(x => x.ShouldMonitorNewAlbum(It.IsAny<Album>(), It.IsAny<List<Album>>(), It.IsAny<NewItemMonitorTypes>()))
.Returns(true);
Mocker.GetMock<IAutoTaggingService>()
.Setup(s => s.GetTagChanges(_artist))
.Returns(new AutoTaggingChanges());
}
private void GivenNewArtistInfo(Artist artist)

@ -66,7 +66,8 @@ namespace NzbDrone.Core.Annotations
OAuth,
Device,
Playlist,
TagSelect
TagSelect,
RootFolder
}
public enum HiddenType

@ -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<int>();
}
public string Name { get; set; }
public List<IAutoTaggingSpecification> Specifications { get; set; }
public bool RemoveTagsAutomatically { get; set; }
public HashSet<int> Tags { get; set; }
}
}

@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace NzbDrone.Core.AutoTagging
{
public class AutoTaggingChanges
{
public HashSet<int> TagsToAdd { get; set; }
public HashSet<int> TagsToRemove { get; set; }
public AutoTaggingChanges()
{
TagsToAdd = new HashSet<int>();
TagsToRemove = new HashSet<int>();
}
}
}

@ -0,0 +1,17 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.AutoTagging
{
public interface IAutoTaggingRepository : IBasicRepository<AutoTag>
{
}
public class AutoTaggingRepository : BasicRepository<AutoTag>, IAutoTaggingRepository
{
public AutoTaggingRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
}
}

@ -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<AutoTag> All();
AutoTag GetById(int id);
void Delete(int id);
List<AutoTag> AllForTag(int tagId);
AutoTaggingChanges GetTagChanges(Artist artist);
}
public class AutoTaggingService : IAutoTaggingService
{
private readonly IAutoTaggingRepository _repository;
private readonly RootFolderService _rootFolderService;
private readonly ICached<Dictionary<int, AutoTag>> _cache;
public AutoTaggingService(IAutoTaggingRepository repository,
RootFolderService rootFolderService,
ICacheManager cacheManager)
{
_repository = repository;
_rootFolderService = rootFolderService;
_cache = cacheManager.GetCache<Dictionary<int, AutoTag>>(typeof(AutoTag), "autoTags");
}
private Dictionary<int, AutoTag> AllDictionary()
{
return _cache.Get("all", () => _repository.All().ToDictionary(m => m.Id));
}
public List<AutoTag> 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<AutoTag> 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;
}
}
}

@ -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<IAutoTaggingSpecification, bool> Matches { get; set; }
public bool DidMatch => !(Matches.Any(m => m.Key.Required && m.Value == false) ||
Matches.All(m => m.Value == false));
}
}

@ -0,0 +1,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);
}
}

@ -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<GenreSpecification>
{
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<string> 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));
}
}
}

@ -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);
}
}

@ -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<RootFolderSpecification>
{
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));
}
}
}

@ -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<List<IAutoTaggingSpecification>>
{
public override void Write(Utf8JsonWriter writer, List<IAutoTaggingSpecification> value, JsonSerializerOptions options)
{
var wrapped = value.Select(x => new SpecificationWrapper
{
Type = x.GetType().Name,
Body = x
});
JsonSerializer.Serialize(writer, wrapped, options);
}
public override List<IAutoTaggingSpecification> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
ValidateToken(reader, JsonTokenType.StartArray);
var results = new List<IAutoTaggingSpecification>();
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; }
}
}
}

@ -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("[]");
}
}
}

@ -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>("NotificationStatus").RegisterModel();
Mapper.Entity<CustomFilter>("CustomFilters").RegisterModel();
Mapper.Entity<ImportListExclusion>("ImportListExclusions").RegisterModel();
Mapper.Entity<DownloadHistory>("DownloadHistory").RegisterModel();
Mapper.Entity<UpdateHistory>("UpdateHistory").RegisterModel();
Mapper.Entity<ImportListExclusion>("ImportListExclusions").RegisterModel();
Mapper.Entity<AutoTagging.AutoTag>("AutoTagging").RegisterModel();
}
private static void RegisterMappers()
@ -217,6 +221,7 @@ namespace NzbDrone.Core.Datastore
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<QualityProfileQualityItem>>(new QualityIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ProfileFormatItem>>(new CustomFormatIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ICustomFormatSpecification>>(new CustomFormatSpecificationListConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<IAutoTaggingSpecification>>(new AutoTaggingSpecificationConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<QualityModel>(new QualityIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<Dictionary<string, string>>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<IDictionary<string, string>>());

@ -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();

@ -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",

@ -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<Artist> artists, bool isNew, CommandTrigger trigger, bool infoUpdated)
private void RescanArtists(List<Artist> 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<int>();
var tagsRemoved = new HashSet<int>();
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);
}
}
}

@ -14,6 +14,7 @@ namespace NzbDrone.Core.Tags
public List<int> ImportListIds { get; set; }
public List<int> RootFolderIds { get; set; }
public List<int> IndexerIds { get; set; }
public List<int> AutoTagIds { get; set; }
public List<int> 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();
}
}

@ -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<TagDetails>();
@ -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(),
});
}

Loading…
Cancel
Save