New: Auto tagging of series

Closes #3870
This commit is contained in:
Mark McDowall 2022-12-05 22:58:53 -08:00 committed by GitHub
parent 8eb941d590
commit 335fc05dd1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 2761 additions and 114 deletions

View File

@ -35,6 +35,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.TEXT;
case 'oAuth':
return inputTypes.OAUTH;
case 'rootFolder':
return inputTypes.ROOT_FOLDER_SELECT;
default:
return inputTypes.TEXT;
}

View File

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

View File

@ -0,0 +1,135 @@
import PropTypes from 'prop-types';
import React, { useCallback, useState } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { icons, kinds } from 'Helpers/Props';
import EditAutoTaggingModal from './EditAutoTaggingModal';
import styles from './AutoTagging.css';
export default function AutoTagging(props) {
const {
id,
name,
tags,
tagList,
specifications,
isDeleting,
onConfirmDeleteAutoTagging,
onCloneAutoTaggingPress
} = props;
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onDeletePress = useCallback(() => {
setIsEditModalOpen(false);
setIsDeleteModalOpen(true);
}, [setIsEditModalOpen, setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onConfirmDelete = useCallback(() => {
onConfirmDeleteAutoTagging(id);
}, [id, onConfirmDeleteAutoTagging]);
const onClonePress = useCallback(() => {
onCloneAutoTaggingPress(id);
}, [id, onCloneAutoTaggingPress]);
return (
<Card
className={styles.autoTagging}
overlayContent={true}
onPress={onEditPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<div>
<IconButton
className={styles.cloneButton}
title="Clone Auto Tag"
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="Delete Auto Tag"
message={`Are you sure you want to delete the auto tag '${name}'?`}
confirmLabel="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
};

View File

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

View File

@ -0,0 +1,103 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { cloneAutoTagging, deleteAutoTagging, fetchAutoTaggings } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByName from 'Utilities/Array/sortByName';
import AutoTagging from './AutoTagging';
import EditAutoTaggingModal from './EditAutoTaggingModal';
import styles from './AutoTaggings.css';
export default function AutoTaggings() {
const {
error,
items,
isDeleting,
isFetching,
isPopulated
} = useSelector(
createSortedSectionSelector('settings.autoTaggings', sortByName)
);
const tagList = useSelector(createTagsSelector());
const dispatch = useDispatch();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [tagsFromId, setTagsFromId] = useState(undefined);
const onClonePress = useCallback((id) => {
dispatch(cloneAutoTagging({ id }));
setTagsFromId(id);
setIsEditModalOpen(true);
}, [dispatch, setIsEditModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback((id) => {
dispatch(deleteAutoTagging({ id }));
}, [dispatch]);
useEffect(() => {
dispatch(fetchAutoTaggings());
dispatch(fetchRootFolders());
}, [dispatch]);
return (
<FieldSet legend="Auto Tagging">
<PageSectionContent
errorMessage="Unable to load auto taggimg"
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>
);
}

View File

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

View File

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

View File

@ -0,0 +1,268 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import {
cloneAutoTaggingSpecification,
deleteAutoTaggingSpecification,
fetchAutoTaggingSpecifications,
saveAutoTagging,
setAutoTaggingValue
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import AddSpecificationModal from './Specifications/AddSpecificationModal';
import EditSpecificationModal from './Specifications/EditSpecificationModal';
import Specification from './Specifications/Specification';
import styles from './EditAutoTaggingModalContent.css';
export default function EditAutoTaggingModalContent(props) {
const {
id,
tagsFromId,
onModalClose,
onDeleteAutoTaggingPress
} = props;
const {
error,
item,
isFetching,
isSaving,
saveError,
validationErrors,
validationWarnings
} = useSelector(createProviderSettingsSelectorHook('autoTaggings', id));
const {
isPopulated: specificationsPopulated,
items: specifications
} = useSelector((state) => state.settings.autoTaggingSpecifications);
const dispatch = useDispatch();
const [isAddSpecificationModalOpen, setIsAddSpecificationModalOpen] = useState(false);
const [isEditSpecificationModalOpen, setIsEditSpecificationModalOpen] = useState(false);
// const [isImportAutoTaggingModalOpen, setIsImportAutoTaggingModalOpen] = useState(false);
const onAddSpecificationPress = useCallback(() => {
setIsAddSpecificationModalOpen(true);
}, [setIsAddSpecificationModalOpen]);
const onAddSpecificationModalClose = useCallback(({ specificationSelected = false } = {}) => {
setIsAddSpecificationModalOpen(false);
setIsEditSpecificationModalOpen(specificationSelected);
}, [setIsAddSpecificationModalOpen]);
const onEditSpecificationModalClose = useCallback(() => {
setIsEditSpecificationModalOpen(false);
}, [setIsEditSpecificationModalOpen]);
const onInputChange = useCallback(({ name, value }) => {
dispatch(setAutoTaggingValue({ name, value }));
}, [dispatch]);
const onSavePress = useCallback(() => {
dispatch(saveAutoTagging({ id }));
}, [dispatch, id]);
const onCloneSpecificationPress = useCallback((specId) => {
dispatch(cloneAutoTaggingSpecification({ id: specId }));
}, [dispatch]);
const onConfirmDeleteSpecification = useCallback((specId) => {
dispatch(deleteAutoTaggingSpecification({ id: specId }));
}, [dispatch]);
useEffect(() => {
dispatch(fetchAutoTaggingSpecifications({ id: tagsFromId || id }));
}, [id, tagsFromId, dispatch]);
const isSavingRef = useRef();
useEffect(() => {
if (isSavingRef.current && !isSaving && !saveError) {
onModalClose();
}
isSavingRef.current = isSaving;
}, [isSaving, saveError, onModalClose]);
const {
name,
removeTagsAutomatically,
tags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Auto Tag' : 'Add Auto Tag'}
</ModalHeader>
<ModalBody>
<div>
{
isFetching ? <LoadingIndicator />: null
}
{
!isFetching && !!error ?
<div>
{'Unable to add a new auto tag, please try again.'}
</div> :
null
}
{
!isFetching && !error && specificationsPopulated ?
<div>
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>
Name
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{'Remove Tags Automatically'}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeTagsAutomatically"
helpText={'Remove tags automatically if conditions are not met'}
{...removeTagsAutomatically}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
<FieldSet legend={'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}
>
Delete
</Button> :
null
}
{/* <Button
className={styles.deleteButton}
onPress={onImportPress}
>
Import
</Button> */}
</div>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditAutoTaggingModalContent.propTypes = {
id: PropTypes.number,
tagsFromId: PropTypes.number,
onModalClose: PropTypes.func.isRequired,
onDeleteAutoTaggingPress: PropTypes.func
};

View File

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

View File

@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem';
import styles from './AddSpecificationItem.css';
export default function AddSpecificationItem(props) {
const {
implementation,
implementationName,
infoLink,
presets,
onSpecificationSelect
} = props;
const onWrappedSpecificationSelect = useCallback(() => {
onSpecificationSelect({ implementation });
}, [implementation, onSpecificationSelect]);
const hasPresets = !!presets && !!presets.length;
return (
<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}
>
Custom
</Button>
<Menu className={styles.presetsMenu}>
<Button
className={styles.presetsMenuButton}
size={sizes.SMALL}
>
Presets
</Button>
<MenuContent>
{
presets.map((preset, index) => {
return (
<AddSpecificationPresetMenuItem
key={index}
name={preset.name}
implementation={implementation}
onPress={onWrappedSpecificationSelect}
/>
);
})
}
</MenuContent>
</Menu>
</span> :
null
}
{
infoLink ?
<Button
to={infoLink}
size={sizes.SMALL}
>
More Info
</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
};

View File

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

View File

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

View File

@ -0,0 +1,105 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import {
fetchAutoTaggingSpecificationSchema,
selectAutoTaggingSpecificationSchema
} from 'Store/Actions/settingsActions';
import AddSpecificationItem from './AddSpecificationItem';
import styles from './AddSpecificationModalContent.css';
export default function AddSpecificationModalContent(props) {
const {
onModalClose
} = props;
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema
} = useSelector(
(state) => state.settings.autoTaggingSpecifications
);
const dispatch = useDispatch();
const onSpecificationSelect = useCallback(({ implementation, name }) => {
dispatch(selectAutoTaggingSpecificationSchema({ implementation, presetName: name }));
onModalClose({ specificationSelected: true });
}, [dispatch, onModalClose]);
useEffect(() => {
dispatch(fetchAutoTaggingSpecificationSchema());
}, [dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add Condition
</ModalHeader>
<ModalBody>
{
isSchemaFetching ? <LoadingIndicator /> : null
}
{
!isSchemaFetching && !!schemaError ?
<div>
{'Unable to add a new condition, please try again.'}
</div> :
null
}
{
isSchemaPopulated && !schemaError ?
<div>
<Alert kind={kinds.INFO}>
<div>
{'Sonarr supports the follow properties for auto tagging rules'}
</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}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
AddSpecificationModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,188 @@
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import {
clearAutoTaggingSpecificationPending,
saveAutoTaggingSpecification,
setAutoTaggingSpecificationFieldValue,
setAutoTaggingSpecificationValue
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import styles from './EditSpecificationModalContent.css';
function EditSpecificationModalContent(props) {
const {
id,
onDeleteSpecificationPress,
onModalClose
} = props;
const advancedSettings = useSelector((state) => state.settings.advancedSettings);
const {
item,
...otherFormProps
} = useSelector(
createProviderSettingsSelectorHook('autoTaggingSpecifications', id)
);
const dispatch = useDispatch();
const onInputChange = useCallback(({ name, value }) => {
dispatch(setAutoTaggingSpecificationValue({ name, value }));
}, [dispatch]);
const onFieldChange = useCallback(({ name, value }) => {
dispatch(setAutoTaggingSpecificationFieldValue({ name, value }));
}, [dispatch]);
const onCancelPress = useCallback(({ name, value }) => {
dispatch(clearAutoTaggingSpecificationPending());
onModalClose();
}, [dispatch, onModalClose]);
const onSavePress = useCallback(({ name, value }) => {
dispatch(saveAutoTaggingSpecification({ id }));
onModalClose();
}, [dispatch, id, onModalClose]);
const {
implementationName,
name,
negate,
required,
fields
} = item;
return (
<ModalContent onModalClose={onCancelPress}>
<ModalHeader>
{`${id ? 'Edit' : 'Add'} Condition - ${implementationName}`}
</ModalHeader>
<ModalBody>
<Form
{...otherFormProps}
>
{
fields && fields.some((x) => x.label === 'Regular Expression') &&
<Alert kind={kinds.INFO}>
<div>
<div dangerouslySetInnerHTML={{ __html: 'This condition matches using Regular Expressions. Note that the characters <code>\\^$.|?*+()[{</code> have special meanings and need escaping with a <code>\\</code>' }} />
{'More details'} <Link to="https://www.regular-expressions.info/tutorial.html">{'Here'}</Link>
</div>
<div>
{'Regular expressions can be tested '}
<Link to="http://regexstorm.net/tester">Here</Link>
</div>
</Alert>
}
<FormGroup>
<FormLabel>
Name
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
{
fields && fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="specifications"
providerData={item}
{...field}
onChange={onFieldChange}
/>
);
})
}
<FormGroup>
<FormLabel>
Negate
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="negate"
{...negate}
helpText={`If checked, the auto tagging rule will not apply if this ${implementationName} condition matches.`}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Required
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="required"
{...required}
helpText={`This ${implementationName} condition must match for the auto tagging rule to apply. Otherwise a single ${implementationName} match is sufficient.`}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
{
id ?
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteSpecificationPress}
>
Delete
</Button> :
null
}
<Button
onPress={onCancelPress}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={false}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditSpecificationModalContent.propTypes = {
id: PropTypes.number,
onDeleteSpecificationPress: PropTypes.func,
onModalClose: PropTypes.func.isRequired
};
export default EditSpecificationModalContent;

View File

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

View File

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

View File

@ -0,0 +1,121 @@
import PropTypes from 'prop-types';
import React, { useCallback, useState } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, kinds } from 'Helpers/Props';
import EditSpecificationModal from './EditSpecificationModal';
import styles from './Specification.css';
export default function Specification(props) {
const {
id,
implementationName,
name,
required,
negate,
onConfirmDeleteSpecification,
onCloneSpecificationPress
} = props;
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onDeletePress = useCallback(() => {
setIsEditModalOpen(false);
setIsDeleteModalOpen(true);
}, [setIsEditModalOpen, setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onConfirmDelete = useCallback(() => {
onConfirmDeleteSpecification(id);
}, [id, onConfirmDeleteSpecification]);
const onClonePress = useCallback(() => {
onCloneSpecificationPress(id);
}, [id, onCloneSpecificationPress]);
return (
<Card
className={styles.autoTagging}
overlayContent={true}
onPress={onEditPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<IconButton
className={styles.cloneButton}
title="Clone"
name={icons.CLONE}
onPress={onClonePress}
/>
</div>
<div className={styles.labels}>
<Label kind={kinds.DEFAULT}>
{implementationName}
</Label>
{
negate ?
<Label kind={kinds.DANGER}>
Negated
</Label> :
null
}
{
required ?
<Label kind={kinds.SUCCESS}>
Required
</Label> :
null
}
</div>
<EditSpecificationModal
id={id}
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onDeleteSpecificationPress={onDeletePress}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Specification"
message={`Are you sure you want to delete specification ${name} ?`}
confirmLabel="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
};

View File

@ -21,6 +21,7 @@ function TagDetailsModalContent(props) {
notifications,
releaseProfiles,
indexers,
autoTags,
onModalClose,
onDeleteTagPress
} = props;
@ -177,6 +178,22 @@ function TagDetailsModalContent(props) {
</FieldSet> :
null
}
{
autoTags.length ?
<FieldSet legend="Auto Tagging">
{
autoTags.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
</ModalBody>
<ModalFooter>
@ -211,6 +228,7 @@ TagDetailsModalContent.propTypes = {
notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
autoTags: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteTagPress: PropTypes.func.isRequired
};

View File

@ -77,6 +77,14 @@ function createMatchingIndexersSelector() {
);
}
function createMatchingAutoTagsSelector() {
return createSelector(
(state, { autoTagIds }) => autoTagIds,
(state) => state.settings.autoTaggings.items,
findMatchingItems
);
}
function createMapStateToProps() {
return createSelector(
createMatchingSeriesSelector(),
@ -85,14 +93,16 @@ function createMapStateToProps() {
createMatchingNotificationsSelector(),
createMatchingReleaseProfilesSelector(),
createMatchingIndexersSelector(),
(series, delayProfiles, importLists, notifications, releaseProfiles, indexers) => {
createMatchingAutoTagsSelector(),
(series, delayProfiles, importLists, notifications, releaseProfiles, indexers, autoTags) => {
return {
series,
delayProfiles,
importLists,
notifications,
releaseProfiles,
indexers
indexers,
autoTags
};
}
);

View File

@ -4,6 +4,7 @@ import Card from 'Components/Card';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { kinds } from 'Helpers/Props';
import TagDetailsModal from './Details/TagDetailsModal';
import TagInUse from './TagInUse';
import styles from './Tag.css';
class Tag extends Component {
@ -57,6 +58,7 @@ class Tag extends Component {
notificationIds,
restrictionIds,
indexerIds,
autoTagIds,
seriesIds
} = this.props;
@ -71,6 +73,7 @@ class Tag extends Component {
notificationIds.length ||
restrictionIds.length ||
indexerIds.length ||
autoTagIds.length ||
seriesIds.length
);
@ -85,57 +88,47 @@ class Tag extends Component {
</div>
{
isTagUsed &&
isTagUsed ?
<div>
{
seriesIds.length ?
<div>
{seriesIds.length} series
</div> :
null
}
<TagInUse
label="series"
count={seriesIds.length}
shouldPluralize={false}
/>
{
delayProfileIds.length ?
<div>
{delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'}
</div> :
null
}
<TagInUse
label="delay profile"
count={delayProfileIds.length}
/>
{
importListIds.length ?
<div>
{importListIds.length} import list{importListIds.length > 1 && 's'}
</div> :
null
}
<TagInUse
label="import list"
count={importListIds.length}
/>
{
notificationIds.length ?
<div>
{notificationIds.length} connection{notificationIds.length > 1 && 's'}
</div> :
null
}
<TagInUse
label="connection"
count={notificationIds.length}
/>
{
restrictionIds.length ?
<div>
{restrictionIds.length} release profile{restrictionIds.length > 1 && 's'}
</div> :
null
}
<TagInUse
label="release profile"
count={restrictionIds.length}
/>
{
indexerIds.length ?
<div>
{indexerIds.length} indexer{indexerIds.length > 1 && 's'}
<TagInUse
label="indexer"
count={indexerIds.length}
/>
<TagInUse
label="auto tagging"
count={autoTagIds.length}
shouldPluralize={false}
/>
</div> :
null
}
</div>
}
{
!isTagUsed &&
@ -153,6 +146,7 @@ class Tag extends Component {
notificationIds={notificationIds}
restrictionIds={restrictionIds}
indexerIds={indexerIds}
autoTagIds={autoTagIds}
isOpen={isDetailsModalOpen}
onModalClose={this.onDetailsModalClose}
onDeleteTagPress={this.onDeleteTagPress}
@ -180,6 +174,7 @@ Tag.propTypes = {
notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired,
seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
onConfirmDeleteTag: PropTypes.func.isRequired
};
@ -190,6 +185,7 @@ Tag.defaultProps = {
notificationIds: [],
restrictionIds: [],
indexerIds: [],
autoTagIds: [],
seriesIds: []
};

View File

@ -0,0 +1,34 @@
import PropTypes from 'prop-types';
import React from 'react';
export default function TagInUse(props) {
const {
label,
count,
shouldPluralize = true
} = props;
if (count === 0) {
return null;
}
if (count > 1 && shouldPluralize) {
return (
<div>
{count} {label}s
</div>
);
}
return (
<div>
{count} {label}
</div>
);
}
TagInUse.propTypes = {
label: PropTypes.string.isRequired,
count: PropTypes.number.isRequired,
shouldPluralize: PropTypes.bool
};

View File

@ -2,6 +2,7 @@ import React from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import AutoTaggings from './AutoTagging/AutoTaggings';
import TagsConnector from './TagsConnector';
function TagSettings() {
@ -13,6 +14,7 @@ function TagSettings() {
<PageContentBody>
<TagsConnector />
<AutoTaggings />
</PageContentBody>
</PageContent>
);

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import { createAction } from 'redux-actions';
import { handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions';
import autoTaggings from './Settings/autoTaggings';
import autoTaggingSpecifications from './Settings/autoTaggingSpecifications';
import customFormats from './Settings/customFormats';
import customFormatSpecifications from './Settings/customFormatSpecifications';
import delayProfiles from './Settings/delayProfiles';
@ -23,6 +25,8 @@ import releaseProfiles from './Settings/releaseProfiles';
import remotePathMappings from './Settings/remotePathMappings';
import ui from './Settings/ui';
export * from './Settings/autoTaggingSpecifications';
export * from './Settings/autoTaggings';
export * from './Settings/customFormatSpecifications.js';
export * from './Settings/customFormats';
export * from './Settings/delayProfiles';
@ -55,7 +59,8 @@ export const section = 'settings';
export const defaultState = {
advancedSettings: false,
autoTaggingSpecifications: autoTaggingSpecifications.defaultState,
autoTaggings: autoTaggings.defaultState,
customFormatSpecifications: customFormatSpecifications.defaultState,
customFormats: customFormats.defaultState,
delayProfiles: delayProfiles.defaultState,
@ -97,6 +102,8 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
// Action Handlers
export const actionHandlers = handleThunks({
...autoTaggingSpecifications.actionHandlers,
...autoTaggings.actionHandlers,
...customFormatSpecifications.actionHandlers,
...customFormats.actionHandlers,
...delayProfiles.actionHandlers,
@ -129,6 +136,8 @@ export const reducers = createHandleActions({
return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
},
...autoTaggingSpecifications.reducers,
...autoTaggings.reducers,
...customFormatSpecifications.reducers,
...customFormats.reducers,
...delayProfiles.reducers,

View File

@ -2,11 +2,7 @@ import _ from 'lodash';
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
function createProviderSettingsSelector(sectionName) {
return createSelector(
(state, { id }) => id,
(state) => state.settings[sectionName],
(id, section) => {
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);
@ -56,8 +52,20 @@ function createProviderSettingsSelector(sectionName) {
...settings,
item: settings.settings
};
}
}
export default function createProviderSettingsSelector(sectionName) {
return createSelector(
(state, { id }) => id,
(state) => state.settings[sectionName],
(id, section) => selector(id, section)
);
}
export function createProviderSettingsSelectorHook(sectionName, id) {
return createSelector(
(state) => state.settings[sectionName],
(section) => selector(id, section)
);
}
export default createProviderSettingsSelector;

View File

@ -0,0 +1,140 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.AutoTagging.Specifications;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.AutoTagging
{
[TestFixture]
public class AutoTaggingServiceFixture : CoreTest<AutoTaggingService>
{
private Series _series;
private AutoTag _tag;
[SetUp]
public void Setup()
{
_series = Builder<Series>.CreateNew()
.With(s => s.Genres = new List<string> { "Comedy" })
.Build();
_tag = new AutoTag
{
Name = "Test",
Specifications = new List<IAutoTaggingSpecification>
{
new GenreSpecification
{
Name = "Genre",
Value = new List<string>
{
"Comedy"
}
}
},
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(_series);
result.TagsToAdd.Should().BeEmpty();
result.TagsToRemove.Should().BeEmpty();
}
[Test]
public void should_have_tags_to_add_if_series_does_not_have_match_tag()
{
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_series);
result.TagsToAdd.Should().HaveCount(1);
result.TagsToAdd.Should().Contain(1);
result.TagsToRemove.Should().BeEmpty();
}
[Test]
public void should_not_have_tags_to_remove_if_series_has_matching_tag_but_remove_is_false()
{
_series.Tags = new HashSet<int> { 1 };
_series.Genres = new List<string> { "NotComedy" };
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_series);
result.TagsToAdd.Should().BeEmpty();
result.TagsToRemove.Should().BeEmpty();
}
[Test]
public void should_have_tags_to_remove_if_series_has_matching_tag_and_remove_is_true()
{
_series.Tags = new HashSet<int> { 1 };
_series.Genres = new List<string> { "NotComedy" };
_tag.RemoveTagsAutomatically = true;
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_series);
result.TagsToAdd.Should().BeEmpty();
result.TagsToRemove.Should().HaveCount(1);
result.TagsToRemove.Should().Contain(1);
}
[Test]
public void should_have_tags_to_add_if_series_does_not_have_match_tag_and_series_matches_all_rules()
{
_tag.Specifications.Add(new SeriesTypeSpecification
{
Name = "Series Type",
Value = (int)_series.SeriesType
});
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_series);
result.TagsToAdd.Should().HaveCount(1);
result.TagsToAdd.Should().Contain(1);
result.TagsToRemove.Should().BeEmpty();
}
[Test]
public void should_match_if_specification_is_negated()
{
_series.Genres = new List<string> { "NotComedy" };
_tag.Specifications.First().Negate = true;
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_series);
result.TagsToAdd.Should().HaveCount(1);
result.TagsToAdd.Should().Contain(1);
result.TagsToRemove.Should().BeEmpty();
}
}
}

View File

@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MetadataSource;
@ -44,6 +44,10 @@ namespace NzbDrone.Core.Test.TvTests
Mocker.GetMock<IProvideSeriesInfo>()
.Setup(s => s.GetSeriesInfo(It.IsAny<int>()))
.Callback<int>(p => { throw new SeriesNotFoundException(p); });
Mocker.GetMock<IAutoTaggingService>()
.Setup(s => s.GetTagChanges(_series))
.Returns(new AutoTaggingChanges());
}
private void GivenNewSeriesInfo(Series series)

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,128 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.AutoTagging
{
public interface IAutoTaggingService
{
void Update(AutoTag autoTag);
AutoTag Insert(AutoTag autoTag);
List<AutoTag> All();
AutoTag GetById(int id);
void Delete(int id);
List<AutoTag> AllForTag(int tagId);
AutoTaggingChanges GetTagChanges(Series series);
}
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(Series series)
{
var autoTags = All();
var changes = new AutoTaggingChanges();
if (autoTags.Empty())
{
return changes;
}
// Set the root folder path on the series
series.RootFolderPath = _rootFolderService.GetBestRootFolderPath(series.Path);
foreach (var autoTag in autoTags)
{
var specificationMatches = autoTag.Specifications
.GroupBy(t => t.GetType())
.Select(g => new SpecificationMatchesGroup
{
Matches = g.ToDictionary(t => t, t => t.IsSatisfiedBy(series))
})
.ToList();
var allMatch = specificationMatches.All(x => x.DidMatch);
var tags = autoTag.Tags;
if (allMatch)
{
foreach (var tag in tags)
{
if (!series.Tags.Contains(tag))
{
changes.TagsToAdd.Add(tag);
}
}
continue;
}
if (autoTag.RemoveTagsAutomatically)
{
foreach (var tag in tags)
{
changes.TagsToRemove.Add(tag);
}
}
}
return changes;
}
}
}

View File

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

View File

@ -0,0 +1,36 @@
using NzbDrone.Core.Tv;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public abstract class AutoTaggingSpecificationBase : IAutoTaggingSpecification
{
public abstract int Order { get; }
public abstract string ImplementationName { get; }
public string Name { get; set; }
public bool Negate { get; set; }
public bool Required { get; set; }
public IAutoTaggingSpecification Clone()
{
return (IAutoTaggingSpecification)MemberwiseClone();
}
public abstract NzbDroneValidationResult Validate();
public bool IsSatisfiedBy(Series series)
{
var match = IsSatisfiedByWithoutNegate(series);
if (Negate)
{
match = !match;
}
return match;
}
protected abstract bool IsSatisfiedByWithoutNegate(Series series);
}
}

View File

@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Tv;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public class GenreSpecificationValidator : AbstractValidator<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(Series series)
{
return series.Genres.Any(genre => Value.Contains(genre));
}
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@ -0,0 +1,18 @@
using NzbDrone.Core.Tv;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public interface IAutoTaggingSpecification
{
int Order { get; }
string ImplementationName { get; }
string Name { get; set; }
bool Negate { get; set; }
bool Required { get; set; }
NzbDroneValidationResult Validate();
IAutoTaggingSpecification Clone();
bool IsSatisfiedBy(Series series);
}
}

View File

@ -0,0 +1,38 @@
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Tv;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public class RootFolderSpecificationValidator : AbstractValidator<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(Series series)
{
return series.RootFolderPath.PathEquals(Value);
}
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@ -0,0 +1,36 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Tv;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public class SeriesTypeSpecificationValidator : AbstractValidator<SeriesTypeSpecification>
{
public SeriesTypeSpecificationValidator()
{
RuleFor(c => (SeriesTypes)c.Value).IsInEnum();
}
}
public class SeriesTypeSpecification : AutoTaggingSpecificationBase
{
private static readonly SeriesTypeSpecificationValidator Validator = new SeriesTypeSpecificationValidator();
public override int Order => 2;
public override string ImplementationName => "Series Type";
[FieldDefinition(1, Label = "Series Type", Type = FieldType.Select, SelectOptions = typeof(SeriesTypes))]
public int Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Series series)
{
return (int)series.SeriesType == Value;
}
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@ -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}, Sonarr.Core", true);
var item = (IAutoTaggingSpecification)JsonSerializer.Deserialize(ref reader, type, options);
results.Add(item);
reader.Read(); // Move past end of body object
reader.Read(); // Move past end of 'wrapper' object
}
ValidateToken(reader, JsonTokenType.EndArray);
return results;
}
// Helper function for validating where you are in the JSON
private void ValidateToken(Utf8JsonReader reader, JsonTokenType tokenType)
{
if (reader.TokenType != tokenType)
{
throw new JsonException($"Invalid token: Was expecting a '{tokenType}' token but received a '{reader.TokenType}' token");
}
}
private class SpecificationWrapper
{
public string Type { get; set; }
public object Body { get; set; }
}
}
}

View File

@ -0,0 +1,18 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(179)]
public class add_auto_tagging : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Create.TableForModel("AutoTagging")
.WithColumn("Name").AsString().Unique()
.WithColumn("Specifications").AsString().WithDefaultValue("[]")
.WithColumn("RemoveTagsAutomatically").AsBoolean().WithDefaultValue(false)
.WithColumn("Tags").AsString().WithDefaultValue("[]");
}
}
}

View File

@ -4,6 +4,7 @@ using System.Linq;
using Dapper;
using NzbDrone.Common.Reflection;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.AutoTagging.Specifications;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFilters;
@ -158,6 +159,8 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity<UpdateHistory>("UpdateHistory").RegisterModel();
Mapper.Entity<ImportListExclusion>("ImportListExclusions").RegisterModel();
Mapper.Entity<AutoTagging.AutoTag>("AutoTagging").RegisterModel();
}
private static void RegisterMappers()
@ -171,6 +174,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>>());

View File

@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers
{
using (var mapper = _database.OpenConnection())
{
var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers" }
var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging" }
.SelectMany(v => GetUsedTags(v, mapper))
.Distinct()
.ToArray();

View File

@ -1,4 +1,4 @@
using NzbDrone.Common.Disk;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Http;
namespace NzbDrone.Core.MediaCover

View File

@ -13,13 +13,8 @@ namespace NzbDrone.Core.Tags
public List<int> DelayProfileIds { get; set; }
public List<int> ImportListIds { get; set; }
public List<int> IndexerIds { get; set; }
public List<int> AutoTagIds { get; set; }
public bool InUse
{
get
{
return SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any();
}
}
public bool InUse => SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any() || AutoTagIds.Any();
}
}

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Indexers;
@ -35,6 +36,7 @@ namespace NzbDrone.Core.Tags
private readonly IReleaseProfileService _releaseProfileService;
private readonly ISeriesService _seriesService;
private readonly IIndexerFactory _indexerService;
private readonly IAutoTaggingService _autoTaggingService;
public TagService(ITagRepository repo,
IEventAggregator eventAggregator,
@ -43,7 +45,8 @@ namespace NzbDrone.Core.Tags
INotificationFactory notificationFactory,
IReleaseProfileService releaseProfileService,
ISeriesService seriesService,
IIndexerFactory indexerService)
IIndexerFactory indexerService,
IAutoTaggingService autoTaggingService)
{
_repo = repo;
_eventAggregator = eventAggregator;
@ -53,6 +56,7 @@ namespace NzbDrone.Core.Tags
_releaseProfileService = releaseProfileService;
_seriesService = seriesService;
_indexerService = indexerService;
_autoTaggingService = autoTaggingService;
}
public Tag GetTag(int tagId)
@ -86,6 +90,7 @@ namespace NzbDrone.Core.Tags
var restrictions = _releaseProfileService.AllForTag(tagId);
var series = _seriesService.AllForTag(tagId);
var indexers = _indexerService.AllForTag(tagId);
var autoTags = _autoTaggingService.AllForTag(tagId);
return new TagDetails
{
@ -96,7 +101,8 @@ namespace NzbDrone.Core.Tags
NotificationIds = notifications.Select(c => c.Id).ToList(),
RestrictionIds = restrictions.Select(c => c.Id).ToList(),
SeriesIds = series.Select(c => c.Id).ToList(),
IndexerIds = indexers.Select(c => c.Id).ToList()
IndexerIds = indexers.Select(c => c.Id).ToList(),
AutoTagIds = autoTags.Select(c => c.Id).ToList()
};
}
@ -109,6 +115,7 @@ namespace NzbDrone.Core.Tags
var restrictions = _releaseProfileService.All();
var series = _seriesService.GetAllSeries();
var indexers = _indexerService.All();
var autotags = _autoTaggingService.All();
var details = new List<TagDetails>();
@ -123,7 +130,8 @@ namespace NzbDrone.Core.Tags
NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
SeriesIds = series.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList()
IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
AutoTagIds = autotags.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
});
}

View File

@ -5,6 +5,7 @@ using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MediaFiles;
@ -26,6 +27,7 @@ namespace NzbDrone.Core.Tv
private readonly IDiskScanService _diskScanService;
private readonly ICheckIfSeriesShouldBeRefreshed _checkIfSeriesShouldBeRefreshed;
private readonly IConfigService _configService;
private readonly IAutoTaggingService _autoTaggingService;
private readonly Logger _logger;
public RefreshSeriesService(IProvideSeriesInfo seriesInfo,
@ -35,6 +37,7 @@ namespace NzbDrone.Core.Tv
IDiskScanService diskScanService,
ICheckIfSeriesShouldBeRefreshed checkIfSeriesShouldBeRefreshed,
IConfigService configService,
IAutoTaggingService autoTaggingService,
Logger logger)
{
_seriesInfo = seriesInfo;
@ -44,6 +47,7 @@ namespace NzbDrone.Core.Tv
_diskScanService = diskScanService;
_checkIfSeriesShouldBeRefreshed = checkIfSeriesShouldBeRefreshed;
_configService = configService;
_autoTaggingService = autoTaggingService;
_logger = logger;
}
@ -189,6 +193,39 @@ namespace NzbDrone.Core.Tv
}
}
private void UpdateTags(Series series)
{
_logger.Trace("Updating tags for {0}", series);
var tagsAdded = new HashSet<int>();
var tagsRemoved = new HashSet<int>();
var changes = _autoTaggingService.GetTagChanges(series);
foreach (var tag in changes.TagsToRemove)
{
if (series.Tags.Contains(tag))
{
series.Tags.Remove(tag);
tagsRemoved.Add(tag);
}
}
foreach (var tag in changes.TagsToAdd)
{
if (!series.Tags.Contains(tag))
{
series.Tags.Add(tag);
tagsAdded.Add(tag);
}
}
if (tagsAdded.Any() || tagsRemoved.Any())
{
_seriesService.UpdateSeries(series);
_logger.Debug("Updated tags for '{0}'. Added: {1}, Removed: {2}", series.Title, tagsAdded.Count, tagsRemoved.Count);
}
}
public void Execute(RefreshSeriesCommand message)
{
var trigger = message.Trigger;
@ -202,6 +239,7 @@ namespace NzbDrone.Core.Tv
try
{
series = RefreshSeriesInfo(message.SeriesId.Value);
UpdateTags(series);
RescanSeries(series, isNew, trigger);
}
catch (SeriesNotFoundException)
@ -211,6 +249,7 @@ namespace NzbDrone.Core.Tv
catch (Exception e)
{
_logger.Error(e, "Couldn't refresh info for {0}", series);
UpdateTags(series);
RescanSeries(series, isNew, trigger);
throw;
}
@ -238,11 +277,13 @@ namespace NzbDrone.Core.Tv
_logger.Error(e, "Couldn't refresh info for {0}", seriesLocal);
}
UpdateTags(series);
RescanSeries(seriesLocal, false, trigger);
}
else
{
_logger.Info("Skipping refresh of series: {0}", seriesLocal.Title);
UpdateTags(series);
RescanSeries(seriesLocal, false, trigger);
}
}

View File

@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.AutoTagging.Specifications;
using Sonarr.Http;
using Sonarr.Http.REST;
using Sonarr.Http.REST.Attributes;
namespace Sonarr.Api.V3.AutoTagging
{
[V3ApiController]
public class AutoTaggingController : RestController<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");
}
});
}
protected 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;
}
}
}

View File

@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.AutoTagging.Specifications;
using Sonarr.Http.ClientSchema;
using Sonarr.Http.REST;
namespace Sonarr.Api.V3.AutoTagging
{
public class AutoTaggingResource : RestResource
{
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public override int Id { get; set; }
public string Name { get; set; }
public bool RemoveTagsAutomatically { get; set; }
public HashSet<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;
}
}
}

View File

@ -0,0 +1,33 @@
using System.Collections.Generic;
using NzbDrone.Core.AutoTagging.Specifications;
using Sonarr.Http.ClientSchema;
using Sonarr.Http.REST;
namespace Sonarr.Api.V3.AutoTagging
{
public class AutoTaggingSpecificationSchema : RestResource
{
public string Name { get; set; }
public string Implementation { get; set; }
public string ImplementationName { get; set; }
public bool Negate { get; set; }
public bool Required { get; set; }
public List<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)
};
}
}
}

View File

@ -13,6 +13,7 @@ namespace Sonarr.Api.V3.Tags
public List<int> NotificationIds { get; set; }
public List<int> RestrictionIds { get; set; }
public List<int> IndexerIds { get; set; }
public List<int> AutoTagIds { get; set; }
public List<int> SeriesIds { get; set; }
}
@ -34,6 +35,7 @@ namespace Sonarr.Api.V3.Tags
NotificationIds = model.NotificationIds,
RestrictionIds = model.RestrictionIds,
IndexerIds = model.IndexerIds,
AutoTagIds = model.AutoTagIds,
SeriesIds = model.SeriesIds
};
}