New: Release Profiles, Frontend updates (#580)

* New: Release Profiles - UI Updates

* New: Release Profiles - API Changes

* New: Release Profiles - Test Updates

* New: Release Profiles - Backend Updates

* New: Interactive Artist Search

* New: Change Montiored on Album Details Page

* New: Show Duration on Album Details Page

* Fixed: Manual Import not working if no albums are Missing

* Fixed: Sort search input by sortTitle

* Fixed: Queue columnLabel throwing JS error
This commit is contained in:
Qstick
2019-02-23 17:39:11 -05:00
committed by GitHub
parent f126eafd26
commit 3f064c94b9
409 changed files with 6882 additions and 3176 deletions
@@ -61,9 +61,7 @@ function EditDelayProfileModalContent(props) {
{
!isFetching && !error &&
<Form
{...otherProps}
>
<Form {...otherProps}>
<FormGroup>
<FormLabel>Protocol</FormLabel>
@@ -16,6 +16,13 @@ const newDelayProfile = {
tags: []
};
const protocolOptions = [
{ key: 'preferUsenet', value: 'Prefer Usenet' },
{ key: 'preferTorrent', value: 'Prefer Torrent' },
{ key: 'onlyUsenet', value: 'Only Usenet' },
{ key: 'onlyTorrent', value: 'Only Torrent' }
];
function createDelayProfileSelector() {
return createSelector(
(state, { id }) => id,
@@ -50,13 +57,6 @@ function createMapStateToProps() {
return createSelector(
createDelayProfileSelector(),
(delayProfile) => {
const protocolOptions = [
{ key: 'preferUsenet', value: 'Prefer Usenet' },
{ key: 'preferTorrent', value: 'Prefer Torrent' },
{ key: 'onlyUsenet', value: 'Only Usenet' },
{ key: 'onlyTorrent', value: 'Only Torrent' }
];
const enableUsenet = delayProfile.item.enableUsenet.value;
const enableTorrent = delayProfile.item.enableTorrent.value;
const preferredProtocol = delayProfile.item.preferredProtocol.value;
@@ -35,6 +35,7 @@ function EditLanguageProfileModalContent(props) {
const {
id,
name,
upgradeAllowed,
cutoff,
languages: itemLanguages
} = item;
@@ -58,9 +59,7 @@ function EditLanguageProfileModalContent(props) {
{
!isFetching && !error &&
<Form
{...otherProps}
>
<Form {...otherProps}>
<FormGroup>
<FormLabel>Name</FormLabel>
@@ -73,19 +72,36 @@ function EditLanguageProfileModalContent(props) {
</FormGroup>
<FormGroup>
<FormLabel>Cutoff</FormLabel>
<FormLabel>
Upgrades Allowed
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
value={cutoff ? cutoff.value.id : 0}
values={languages}
helpText="Once this language is reached Lidarr will no longer download albums"
onChange={onCutoffChange}
type={inputTypes.CHECK}
name="upgradeAllowed"
{...upgradeAllowed}
helpText="If disabled languages will not be upgraded"
onChange={onInputChange}
/>
</FormGroup>
{
upgradeAllowed.value &&
<FormGroup>
<FormLabel>Upgrade Until</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
value={cutoff ? cutoff.value.id : 0}
values={languages}
helpText="Once this language is reached Sonarr will no longer download episodes"
onChange={onCutoffChange}
/>
</FormGroup>
}
<LanguageProfileItems
languageProfileItems={itemLanguages.value}
errors={itemLanguages.errors}
@@ -64,6 +64,7 @@ class LanguageProfile extends Component {
const {
id,
name,
upgradeAllowed,
cutoff,
languages,
isDeleting
@@ -95,13 +96,13 @@ class LanguageProfile extends Component {
return null;
}
const isCutoff = item.language.id === cutoff.id;
const isCutoff = upgradeAllowed && item.language.id === cutoff.id;
return (
<Label
key={item.language.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
title={isCutoff ? 'Upgrade until this language is met or exceeded' : null}
>
{item.language.name}
</Label>
@@ -135,6 +136,7 @@ class LanguageProfile extends Component {
LanguageProfile.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
upgradeAllowed: PropTypes.bool.isRequired,
cutoff: PropTypes.object.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
@@ -61,9 +61,7 @@ function EditMetadataProfileModalContent(props) {
{
!isFetching && !error &&
<Form
{...otherProps}
>
<Form {...otherProps}>
<FormGroup>
<FormLabel>Name</FormLabel>
@@ -8,6 +8,7 @@ import QualityProfilesConnector from './Quality/QualityProfilesConnector';
import LanguageProfilesConnector from './Language/LanguageProfilesConnector';
import MetadataProfilesConnector from './Metadata/MetadataProfilesConnector';
import DelayProfilesConnector from './Delay/DelayProfilesConnector';
import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector';
class Profiles extends Component {
@@ -26,6 +27,7 @@ class Profiles extends Component {
<LanguageProfilesConnector />
<MetadataProfilesConnector />
<DelayProfilesConnector />
<ReleaseProfilesConnector />
</PageContentBodyConnector>
</PageContent>
);
@@ -105,6 +105,7 @@ class EditQualityProfileModalContent extends Component {
const {
id,
name,
upgradeAllowed,
cutoff,
items
} = item;
@@ -139,9 +140,7 @@ class EditQualityProfileModalContent extends Component {
{
!isFetching && !error &&
<Form
{...otherProps}
>
<Form {...otherProps}>
<div className={styles.formGroupsContainer}>
<div className={styles.formGroupWrapper}>
<FormGroup size={sizes.EXTRA_SMALL}>
@@ -159,18 +158,35 @@ class EditQualityProfileModalContent extends Component {
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Cutoff
Upgrades Allowed
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
values={qualities}
helpText="Once this quality is reached Lidarr will no longer download albums"
onChange={onCutoffChange}
type={inputTypes.CHECK}
name="upgradeAllowed"
{...upgradeAllowed}
helpText="If disabled qualities will not be upgraded"
onChange={onInputChange}
/>
</FormGroup>
{
upgradeAllowed.value &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Upgrade Until
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
values={qualities}
helpText="Once this quality is reached Sonarr will no longer download episodes"
onChange={onCutoffChange}
/>
</FormGroup>
}
</div>
<div className={styles.formGroupWrapper}>
@@ -65,6 +65,7 @@ class QualityProfile extends Component {
const {
id,
name,
upgradeAllowed,
cutoff,
items,
isDeleting
@@ -97,20 +98,20 @@ class QualityProfile extends Component {
}
if (item.quality) {
const isCutoff = item.quality.id === cutoff;
const isCutoff = upgradeAllowed && item.quality.id === cutoff;
return (
<Label
key={item.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
title={isCutoff ? 'Upgrade until this quality is met or exceeded' : null}
>
{item.quality.name}
</Label>
);
}
const isCutoff = item.id === cutoff;
const isCutoff = upgradeAllowed && item.id === cutoff;
return (
<Tooltip
@@ -174,6 +175,7 @@ class QualityProfile extends Component {
QualityProfile.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
upgradeAllowed: PropTypes.bool.isRequired,
cutoff: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
@@ -0,0 +1,27 @@
import PropTypes from 'prop-types';
import React from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector';
function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditReleaseProfileModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditReleaseProfileModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditReleaseProfileModal;
@@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditReleaseProfileModal from './EditReleaseProfileModal';
const mapDispatchToProps = {
clearPendingChanges
};
class EditReleaseProfileModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.releaseProfiles' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<EditReleaseProfileModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditReleaseProfileModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector);
@@ -0,0 +1,5 @@
.deleteButton {
composes: button from 'Components/Link/Button.css';
margin-right: auto;
}
@@ -0,0 +1,161 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import styles from './EditReleaseProfileModalContent.css';
// Tab, enter, and comma
const tagInputDelimiters = [9, 13, 188];
function EditReleaseProfileModalContent(props) {
const {
isSaving,
saveError,
item,
onInputChange,
onModalClose,
onSavePress,
onDeleteReleaseProfilePress,
...otherProps
} = props;
const {
id,
required,
ignored,
preferred,
includePreferredWhenRenaming,
tags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Release Profile' : 'Add Release Profile'}
</ModalHeader>
<ModalBody>
<Form {...otherProps}>
<FormGroup>
<FormLabel>Must Contain</FormLabel>
<FormInputGroup
type={inputTypes.TEXT_TAG}
name="required"
helpText="The release must contain at least one of these terms (case insensitive)"
kind={kinds.SUCCESS}
placeholder="Add new restriction"
delimiters={tagInputDelimiters}
{...required}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Must Not Contain</FormLabel>
<FormInputGroup
type={inputTypes.TEXT_TAG}
name="ignored"
helpText="The release will be rejected if it contains one or more of terms (case insensitive)"
kind={kinds.DANGER}
placeholder="Add new restriction"
delimiters={tagInputDelimiters}
{...ignored}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Preferred</FormLabel>
<FormInputGroup
type={inputTypes.KEY_VALUE_LIST}
name="preferred"
helpTexts={[
'The release will be preferred based on the each term\'s score (case insensitive)',
'A positive score will be more preferred',
'A negative score will be less preferred'
]}
{...preferred}
keyPlaceholder="Term"
valuePlaceholder="Score"
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Include Preferred when Renaming</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePreferredWhenRenaming"
helpText="Include in {Preferred Words} renaming format"
{...includePreferredWhenRenaming}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText="Release profiles will apply to artists with at least one matching tag. Leave blank to apply to all artists"
{...tags}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteReleaseProfilePress}
>
Delete
</Button>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditReleaseProfileModalContent.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onDeleteReleaseProfilePress: PropTypes.func
};
export default EditReleaseProfileModalContent;
@@ -0,0 +1,113 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
import { setReleaseProfileValue, saveReleaseProfile } from 'Store/Actions/settingsActions';
import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
const newReleaseProfile = {
required: '',
ignored: '',
preferred: [],
includePreferredWhenRenaming: false,
tags: []
};
function createMapStateToProps() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.releaseProfiles,
(id, releaseProfiles) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = releaseProfiles;
const profile = id ? _.find(items, { id }) : newReleaseProfile;
const settings = selectSettings(profile, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
const mapDispatchToProps = {
setReleaseProfileValue,
saveReleaseProfile
};
class EditReleaseProfileModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.id) {
Object.keys(newReleaseProfile).forEach((name) => {
this.props.setReleaseProfileValue({
name,
value: newReleaseProfile[name]
});
});
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setReleaseProfileValue({ name, value });
}
onSavePress = () => {
this.props.saveReleaseProfile({ id: this.props.id });
}
//
// Render
render() {
return (
<EditReleaseProfileModalContent
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
EditReleaseProfileModalContentConnector.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setReleaseProfileValue: PropTypes.func.isRequired,
saveReleaseProfile: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector);
@@ -0,0 +1,11 @@
.releaseProfile {
composes: card from 'Components/Card.css';
width: 290px;
}
.enabled {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
}
@@ -0,0 +1,168 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import split from 'Utilities/String/split';
import { kinds } from 'Helpers/Props';
import Card from 'Components/Card';
import Label from 'Components/Label';
import TagList from 'Components/TagList';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
import styles from './ReleaseProfile.css';
class ReleaseProfile extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditReleaseProfileModalOpen: false,
isDeleteReleaseProfileModalOpen: false
};
}
//
// Listeners
onEditReleaseProfilePress = () => {
this.setState({ isEditReleaseProfileModalOpen: true });
}
onEditReleaseProfileModalClose = () => {
this.setState({ isEditReleaseProfileModalOpen: false });
}
onDeleteReleaseProfilePress = () => {
this.setState({
isEditReleaseProfileModalOpen: false,
isDeleteReleaseProfileModalOpen: true
});
}
onDeleteReleaseProfileModalClose= () => {
this.setState({ isDeleteReleaseProfileModalOpen: false });
}
onConfirmDeleteReleaseProfile = () => {
this.props.onConfirmDeleteReleaseProfile(this.props.id);
}
//
// Render
render() {
const {
id,
required,
ignored,
preferred,
tags,
tagList
} = this.props;
return (
<Card
className={styles.releaseProfile}
overlayContent={true}
onPress={this.onEditReleaseProfilePress}
>
<div>
{
split(required).map((item) => {
if (!item) {
return null;
}
return (
<Label
key={item}
kind={kinds.SUCCESS}
>
{item}
</Label>
);
})
}
</div>
<div>
{
split(ignored).map((item) => {
if (!item) {
return null;
}
return (
<Label
key={item}
kind={kinds.DANGER}
>
{item}
</Label>
);
})
}
</div>
<div>
{
preferred.map((item) => {
const isPreferred = item.value >= 0;
return (
<Label
key={item.key}
kind={isPreferred ? kinds.DEFAULT : kinds.WARNING}
>
{item.key} {isPreferred && '+'}{item.value}
</Label>
);
})
}
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<EditReleaseProfileModalConnector
id={id}
isOpen={this.state.isEditReleaseProfileModalOpen}
onModalClose={this.onEditReleaseProfileModalClose}
onDeleteReleaseProfilePress={this.onDeleteReleaseProfilePress}
/>
<ConfirmModal
isOpen={this.state.isDeleteReleaseProfileModalOpen}
kind={kinds.DANGER}
title="Delete ReleaseProfile"
message={'Are you sure you want to delete this releaseProfile?'}
confirmLabel="Delete"
onConfirm={this.onConfirmDeleteReleaseProfile}
onCancel={this.onDeleteReleaseProfileModalClose}
/>
</Card>
);
}
}
ReleaseProfile.propTypes = {
id: PropTypes.number.isRequired,
required: PropTypes.string.isRequired,
ignored: PropTypes.string.isRequired,
preferred: PropTypes.arrayOf(PropTypes.object).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
};
ReleaseProfile.defaultProps = {
required: '',
ignored: '',
preferred: []
};
export default ReleaseProfile;
@@ -0,0 +1,20 @@
.releaseProfiles {
display: flex;
flex-wrap: wrap;
}
.addReleaseProfile {
composes: releaseProfile from './ReleaseProfile.css';
background-color: $cardAlternateBackgroundColor;
color: $gray;
text-align: center;
}
.center {
display: inline-block;
padding: 5px 20px 0;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
}
@@ -0,0 +1,98 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Card from 'Components/Card';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import ReleaseProfile from './ReleaseProfile';
import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
import styles from './ReleaseProfiles.css';
class ReleaseProfiles extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddReleaseProfileModalOpen: false
};
}
//
// Listeners
onAddReleaseProfilePress = () => {
this.setState({ isAddReleaseProfileModalOpen: true });
}
onAddReleaseProfileModalClose = () => {
this.setState({ isAddReleaseProfileModalOpen: false });
}
//
// Render
render() {
const {
items,
tagList,
onConfirmDeleteReleaseProfile,
...otherProps
} = this.props;
return (
<FieldSet legend="Release Profiles">
<PageSectionContent
errorMessage="Unable to load ReleaseProfiles"
{...otherProps}
>
<div className={styles.releaseProfiles}>
<Card
className={styles.addReleaseProfile}
onPress={this.onAddReleaseProfilePress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
{
items.map((item) => {
return (
<ReleaseProfile
key={item.id}
tagList={tagList}
{...item}
onConfirmDeleteReleaseProfile={onConfirmDeleteReleaseProfile}
/>
);
})
}
</div>
<EditReleaseProfileModalConnector
isOpen={this.state.isAddReleaseProfileModalOpen}
onModalClose={this.onAddReleaseProfileModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
ReleaseProfiles.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
};
export default ReleaseProfiles;
@@ -0,0 +1,61 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchReleaseProfiles, deleteReleaseProfile } from 'Store/Actions/settingsActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import ReleaseProfiles from './ReleaseProfiles';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.releaseProfiles,
createTagsSelector(),
(releaseProfiles, tagList) => {
return {
...releaseProfiles,
tagList
};
}
);
}
const mapDispatchToProps = {
fetchReleaseProfiles,
deleteReleaseProfile
};
class ReleaseProfilesConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchReleaseProfiles();
}
//
// Listeners
onConfirmDeleteReleaseProfile = (id) => {
this.props.deleteReleaseProfile({ id });
}
//
// Render
render() {
return (
<ReleaseProfiles
{...this.props}
onConfirmDeleteReleaseProfile={this.onConfirmDeleteReleaseProfile}
/>
);
}
}
ReleaseProfilesConnector.propTypes = {
fetchReleaseProfiles: PropTypes.func.isRequired,
deleteReleaseProfile: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector);