New: Get Custom Formats Working in Aphrodite

This commit is contained in:
Qstick 2019-08-31 02:45:22 -04:00
parent 86dde88fe6
commit b2268c7452
35 changed files with 1066 additions and 50 deletions

View File

@ -1,3 +1,4 @@
export const QUALITY_PROFILE_ITEM = 'qualityProfileItem';
export const QUALITY_PROFILE_FORMAT_ITEM = 'qualityProfileFormatItem';
export const DELAY_PROFILE = 'delayProfile';
export const TABLE_COLUMN = 'tableColumn';

View File

@ -1,11 +1,12 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import split from 'Utilities/String/split';
import { icons, kinds } from 'Helpers/Props';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
// import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
import styles from './CustomFormat.css';
class CustomFormat extends Component {
@ -62,15 +63,15 @@ class CustomFormat extends Component {
render() {
const {
// id,
id,
name,
items,
formatTags,
isDeleting
} = this.props;
return (
<Card
className={styles.CustomFormat}
className={styles.customFormat}
overlayContent={true}
onPress={this.onEditCustomFormatPress}
>
@ -87,32 +88,31 @@ class CustomFormat extends Component {
/>
</div>
<div className={styles.formats}>
<div>
{
items.map((item) => {
if (!item.allowed) {
split(formatTags).map((item) => {
if (!item) {
return null;
}
return (
<Label
key={item.quality.id}
kind={kinds.default}
title={null}
key={item}
kind={kinds.DEFAULT}
>
{item.quality.name}
{item}
</Label>
);
})
}
</div>
{/* <EditCustomFormatModalConnector
<EditCustomFormatModalConnector
id={id}
isOpen={this.state.isEditCustomFormatModalOpen}
onModalClose={this.onEditCustomFormatModalClose}
onDeleteCustomFormatPress={this.onDeleteCustomFormatPress}
/> */}
/>
<ConfirmModal
isOpen={this.state.isDeleteCustomFormatModalOpen}
@ -132,7 +132,7 @@ class CustomFormat extends Component {
CustomFormat.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
formatTags: PropTypes.string.isRequired,
isDeleting: PropTypes.bool.isRequired,
onConfirmDeleteCustomFormat: PropTypes.func.isRequired,
onCloneCustomFormatPress: PropTypes.func.isRequired

View File

@ -7,7 +7,7 @@ import Card from 'Components/Card';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import CustomFormat from './CustomFormat';
// import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
import styles from './CustomFormats.css';
class CustomFormats extends Component {
@ -57,7 +57,7 @@ class CustomFormats extends Component {
errorMessage="Unable to load Custom Formats"
{...otherProps}c={true}
>
<div className={styles.CustomFormats}>
<div className={styles.customFormats}>
{
items.sort(sortByName).map((item) => {
return (
@ -85,11 +85,10 @@ class CustomFormats extends Component {
</Card>
</div>
{/*
<EditCustomFormatModalConnector
isOpen={this.state.isCustomFormatModalOpen}
onModalClose={this.onModalClose}
/> */}
/>
</PageSectionContent>
</FieldSet>

View File

@ -0,0 +1,61 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import EditCustomFormatModalContentConnector from './EditCustomFormatModalContentConnector';
class EditCustomFormatModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
height: 'auto'
};
}
//
// Listeners
onContentHeightChange = (height) => {
if (this.state.height === 'auto' || height > this.state.height) {
this.setState({ height });
}
}
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
style={{ height: `${this.state.height}px` }}
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={onModalClose}
>
<EditCustomFormatModalContentConnector
{...otherProps}
onContentHeightChange={this.onContentHeightChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
EditCustomFormatModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditCustomFormatModal;

View File

@ -0,0 +1,43 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditCustomFormatModal from './EditCustomFormatModal';
function mapStateToProps() {
return {};
}
const mapDispatchToProps = {
clearPendingChanges
};
class EditCustomFormatModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.customFormats' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<EditCustomFormatModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditCustomFormatModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(EditCustomFormatModalConnector);

View File

@ -0,0 +1,18 @@
.formGroupsContainer {
display: flex;
flex-wrap: wrap;
}
.formGroupWrapper {
flex: 0 0 calc($formGroupSmallWidth - 100px);
}
.deleteButtonContainer {
margin-right: auto;
}
@media only screen and (max-width: $breakpointLarge) {
.formGroupsContainer {
display: block;
}
}

View File

@ -0,0 +1,131 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 './EditCustomFormatModalContent.css';
class EditCustomFormatModalContent extends Component {
//
// Render
render() {
const {
isFetching,
error,
isSaving,
saveError,
item,
onInputChange,
onSavePress,
onModalClose,
...otherProps
} = this.props;
const {
id,
name,
formatTags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Custom Format' : 'Add Custom Format'}
</ModalHeader>
<ModalBody>
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new custom format, please try again.</div>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<div className={styles.formGroupsContainer}>
<div className={styles.formGroupWrapper}>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Name
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Format Tags
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT_TAG}
name="formatTags"
{...formatTags}
onChange={onInputChange}
/>
</FormGroup>
</div>
</div>
</Form>
}
</div>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
}
EditCustomFormatModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onContentHeightChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditCustomFormatModalContent;

View File

@ -0,0 +1,74 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import { setCustomFormatValue, saveCustomFormat } from 'Store/Actions/settingsActions';
import EditCustomFormatModalContent from './EditCustomFormatModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('customFormats'),
(advancedSettings, customFormat) => {
return {
advancedSettings,
...customFormat
};
}
);
}
const mapDispatchToProps = {
setCustomFormatValue,
saveCustomFormat
};
class EditCustomFormatModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setCustomFormatValue({ name, value });
}
onSavePress = () => {
this.props.saveCustomFormat({ id: this.props.id });
}
//
// Render
render() {
return (
<EditCustomFormatModalContent
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
/>
);
}
}
EditCustomFormatModalContentConnector.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setCustomFormatValue: PropTypes.func.isRequired,
saveCustomFormat: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditCustomFormatModalContentConnector);

View File

@ -15,6 +15,7 @@ import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import QualityProfileItems from './QualityProfileItems';
import QualityProfileFormatItems from './QualityProfileFormatItems';
import styles from './EditQualityProfileModalContent.css';
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
@ -92,6 +93,7 @@ class EditQualityProfileModalContent extends Component {
isSaving,
saveError,
qualities,
customFormats,
languages,
item,
isInUse,
@ -109,11 +111,13 @@ class EditQualityProfileModalContent extends Component {
name,
upgradeAllowed,
cutoff,
formatCutoff,
language,
items
items,
formatItems
} = item;
const languageId = language.value.id;
const languageId = language ? language.value.id : 0;
return (
<ModalContent onModalClose={onModalClose}>
@ -181,7 +185,7 @@ class EditQualityProfileModalContent extends Component {
upgradeAllowed.value &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Upgrade Until
Upgrade Until Quality
</FormLabel>
<FormInputGroup
@ -195,6 +199,24 @@ class EditQualityProfileModalContent extends Component {
</FormGroup>
}
{
upgradeAllowed.value &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Upgrade Until Format
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="formatCutoff"
{...formatCutoff}
values={customFormats}
helpText="Once this custom format is reached Radarr will no longer download movies"
onChange={onCutoffChange}
/>
</FormGroup>
}
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Language
@ -220,6 +242,15 @@ class EditQualityProfileModalContent extends Component {
{...otherProps}
/>
</div>
<div className={styles.formGroupWrapper}>
<QualityProfileFormatItems
profileFormatItems={formatItems.value}
errors={formatItems.errors}
warnings={formatItems.warnings}
{...otherProps}
/>
</div>
</div>
</Form>
@ -282,6 +313,7 @@ EditQualityProfileModalContent.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
item: PropTypes.object.isRequired,
isInUse: PropTypes.bool.isRequired,

View File

@ -61,6 +61,36 @@ function createQualitiesSelector() {
);
}
function createFormatsSelector() {
return createSelector(
createProviderSettingsSelector('qualityProfiles'),
(customFormat) => {
const items = customFormat.item.formatItems;
if (!items || !items.value) {
return [];
}
return _.reduceRight(items.value, (result, { allowed, id, name, format }) => {
if (allowed) {
if (id) {
result.push({
key: id,
value: name
});
} else {
result.push({
key: format.id,
value: format.name
});
}
}
return result;
}, []);
}
);
}
function createLanguagesSelector() {
return createSelector(
(state) => state.settings.languages,
@ -87,11 +117,13 @@ function createMapStateToProps() {
return createSelector(
createProviderSettingsSelector('qualityProfiles'),
createQualitiesSelector(),
createFormatsSelector(),
createLanguagesSelector(),
createProfileInUseSelector('qualityProfileId'),
(qualityProfile, qualities, languages, isInUse) => {
(qualityProfile, qualities, customFormats, languages, isInUse) => {
return {
qualities,
customFormats,
languages,
...qualityProfile,
isInUse
@ -161,6 +193,30 @@ class EditQualityProfileModalContentConnector extends Component {
}
}
ensureFormatCutoff = (qualityProfile) => {
const cutoff = qualityProfile.formatCutoff.value;
const cutoffItem = _.find(qualityProfile.formatItems.value, (i) => {
if (!cutoff) {
return false;
}
return i.id === cutoff || (i.format && i.format.id === cutoff);
});
// If the cutoff isn't allowed anymore or there isn't a cutoff set one
if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
const firstAllowed = _.find(qualityProfile.formatItems.value, { allowed: true });
let cutoffId = null;
if (firstAllowed) {
cutoffId = firstAllowed.format ? firstAllowed.format.id : firstAllowed.id;
}
this.props.setQualityProfileValue({ name: 'formatCutoff', value: cutoffId });
}
}
//
// Listeners
@ -211,6 +267,21 @@ class EditQualityProfileModalContentConnector extends Component {
this.ensureCutoff(qualityProfile);
}
onQualityProfileFormatItemAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item);
const formatItems = qualityProfile.formatItems.value;
const item = _.find(qualityProfile.formatItems.value, (i) => i.format && i.format.id === id);
item.allowed = allowed;
this.props.setQualityProfileValue({
name: 'formatItems',
value: formatItems
});
this.ensureFormatCutoff(qualityProfile);
}
onItemGroupAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
@ -427,6 +498,39 @@ class EditQualityProfileModalContentConnector extends Component {
});
}
onQualityProfileFormatItemDragMove = (dragIndex, dropIndex) => {
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
this.setState({
dragIndex,
dropIndex
});
}
}
onQualityProfileFormatItemDragEnd = ({ id }, didDrop) => {
const {
dragIndex,
dropIndex
} = this.state;
if (didDrop && dropIndex !== null) {
const qualityProfile = _.cloneDeep(this.props.item);
const formats = qualityProfile.formatItems.value.splice(dragIndex, 1);
qualityProfile.formatItems.value.splice(dropIndex, 0, formats[0]);
this.props.setQualityProfileValue({
name: 'formatItems',
value: qualityProfile.formatItems.value
});
}
this.setState({
dragIndex: null,
dropIndex: null
});
}
onToggleEditGroupsMode = () => {
this.setState({ editGroups: !this.state.editGroups });
}
@ -450,10 +554,13 @@ class EditQualityProfileModalContentConnector extends Component {
onCreateGroupPress={this.onCreateGroupPress}
onDeleteGroupPress={this.onDeleteGroupPress}
onQualityProfileItemAllowedChange={this.onQualityProfileItemAllowedChange}
onQualityProfileFormatItemAllowedChange={this.onQualityProfileFormatItemAllowedChange}
onItemGroupAllowedChange={this.onItemGroupAllowedChange}
onItemGroupNameChange={this.onItemGroupNameChange}
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onQualityProfileFormatItemDragMove={this.onQualityProfileFormatItemDragMove}
onQualityProfileFormatItemDragEnd={this.onQualityProfileFormatItemDragEnd}
onToggleEditGroupsMode={this.onToggleEditGroupsMode}
/>
);

View File

@ -0,0 +1,44 @@
.qualityProfileFormatItem {
display: flex;
align-items: stretch;
width: 100%;
border: 1px solid #aaa;
border-radius: 4px;
background: #fafafa;
}
.checkContainer {
position: relative;
margin-right: 4px;
margin-bottom: 7px;
margin-left: 8px;
}
.formatName {
display: flex;
flex-grow: 1;
margin-bottom: 0;
margin-left: 2px;
font-weight: normal;
line-height: 36px;
cursor: pointer;
}
.dragHandle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
width: $dragHandleWidth;
text-align: center;
cursor: grab;
}
.dragIcon {
top: 0;
}
.isDragging {
opacity: 0.25;
}

View File

@ -0,0 +1,83 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import CheckInput from 'Components/Form/CheckInput';
import styles from './QualityProfileFormatItem.css';
class QualityProfileFormatItem extends Component {
//
// Listeners
onAllowedChange = ({ value }) => {
const {
formatId,
onQualityProfileFormatItemAllowedChange
} = this.props;
onQualityProfileFormatItemAllowedChange(formatId, value);
}
//
// Render
render() {
const {
name,
allowed,
isDragging,
connectDragSource
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileFormatItem,
isDragging && styles.isDragging
)}
>
<label
className={styles.formatName}
>
<CheckInput
containerClassName={styles.checkContainer}
name={name}
value={allowed}
onChange={this.onAllowedChange}
/>
{name}
</label>
{
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
/>
</div>
)
}
</div>
);
}
}
QualityProfileFormatItem.propTypes = {
formatId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
sortIndex: PropTypes.number.isRequired,
isDragging: PropTypes.bool.isRequired,
connectDragSource: PropTypes.func,
onQualityProfileFormatItemAllowedChange: PropTypes.func
};
QualityProfileFormatItem.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default QualityProfileFormatItem;

View File

@ -0,0 +1,4 @@
.dragPreview {
width: 380px;
opacity: 0.75;
}

View File

@ -0,0 +1,88 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { DragLayer } from 'react-dnd';
import dimensions from 'Styles/Variables/dimensions.js';
import { QUALITY_PROFILE_FORMAT_ITEM } from 'Helpers/dragTypes';
import DragPreviewLayer from 'Components/DragPreviewLayer';
import QualityProfileFormatItem from './QualityProfileFormatItem';
import styles from './QualityProfileFormatItemDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
function collectDragLayer(monitor) {
return {
item: monitor.getItem(),
itemType: monitor.getItemType(),
currentOffset: monitor.getSourceClientOffset()
};
}
class QualityProfileFormatItemDragPreview extends Component {
//
// Render
render() {
const {
item,
itemType,
currentOffset
} = this.props;
if (!currentOffset || itemType !== QUALITY_PROFILE_FORMAT_ITEM) {
return null;
}
// The offset is shifted because the drag handle is on the right edge of the
// list item and the preview is wider than the drag handle.
const { x, y } = currentOffset;
const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = {
position: 'absolute',
WebkitTransform: transform,
msTransform: transform,
transform
};
const {
formatId,
name,
allowed,
sortIndex
} = item;
return (
<DragPreviewLayer>
<div
className={styles.dragPreview}
style={style}
>
<QualityProfileFormatItem
formatId={formatId}
name={name}
allowed={allowed}
sortIndex={sortIndex}
isDragging={false}
/>
</div>
</DragPreviewLayer>
);
}
}
QualityProfileFormatItemDragPreview.propTypes = {
item: PropTypes.object,
itemType: PropTypes.string,
currentOffset: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
})
};
export default DragLayer(collectDragLayer)(QualityProfileFormatItemDragPreview);

View File

@ -0,0 +1,18 @@
.qualityProfileFormatItemDragSource {
padding: 4px 0;
}
.qualityProfileFormatItemPlaceholder {
width: 100%;
height: 36px;
border: 1px dotted #aaa;
border-radius: 4px;
}
.qualityProfileFormatItemPlaceholderBefore {
margin-bottom: 8px;
}
.qualityProfileFormatItemPlaceholderAfter {
margin-top: 8px;
}

View File

@ -0,0 +1,157 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { findDOMNode } from 'react-dom';
import { DragSource, DropTarget } from 'react-dnd';
import classNames from 'classnames';
import { QUALITY_PROFILE_FORMAT_ITEM } from 'Helpers/dragTypes';
import QualityProfileFormatItem from './QualityProfileFormatItem';
import styles from './QualityProfileFormatItemDragSource.css';
const qualityProfileFormatItemDragSource = {
beginDrag({ formatId, name, allowed, sortIndex }) {
return {
formatId,
name,
allowed,
sortIndex
};
},
endDrag(props, monitor, component) {
props.onQualityProfileFormatItemDragEnd(monitor.getItem(), monitor.didDrop());
}
};
const qualityProfileFormatItemDropTarget = {
hover(props, monitor, component) {
const dragIndex = monitor.getItem().sortIndex;
const hoverIndex = props.sortIndex;
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// Moving up, only trigger if drag position is above 50%
if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Moving down, only trigger if drag position is below 50%
if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
props.onQualityProfileFormatItemDragMove(dragIndex, hoverIndex);
}
};
function collectDragSource(connect, monitor) {
return {
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging()
};
}
function collectDropTarget(connect, monitor) {
return {
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver()
};
}
class QualityProfileFormatItemDragSource extends Component {
//
// Render
render() {
const {
formatId,
name,
allowed,
sortIndex,
isDragging,
isDraggingUp,
isDraggingDown,
isOver,
connectDragSource,
connectDropTarget,
onQualityProfileFormatItemAllowedChange
} = this.props;
const isBefore = !isDragging && isDraggingUp && isOver;
const isAfter = !isDragging && isDraggingDown && isOver;
// if (isDragging && !isOver) {
// return null;
// }
return connectDropTarget(
<div
className={classNames(
styles.qualityProfileFormatItemDragSource,
isBefore && styles.isDraggingUp,
isAfter && styles.isDraggingDown
)}
>
{
isBefore &&
<div
className={classNames(
styles.qualityProfileFormatItemPlaceholder,
styles.qualityProfileFormatItemPlaceholderBefore
)}
/>
}
<QualityProfileFormatItem
formatId={formatId}
name={name}
allowed={allowed}
sortIndex={sortIndex}
isDragging={isDragging}
isOver={isOver}
connectDragSource={connectDragSource}
onQualityProfileFormatItemAllowedChange={onQualityProfileFormatItemAllowedChange}
/>
{
isAfter &&
<div
className={classNames(
styles.qualityProfileFormatItemPlaceholder,
styles.qualityProfileFormatItemPlaceholderAfter
)}
/>
}
</div>
);
}
}
QualityProfileFormatItemDragSource.propTypes = {
formatId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
sortIndex: PropTypes.number.isRequired,
isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool,
isDraggingDown: PropTypes.bool,
isOver: PropTypes.bool,
connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func,
onQualityProfileFormatItemAllowedChange: PropTypes.func.isRequired,
onQualityProfileFormatItemDragMove: PropTypes.func.isRequired,
onQualityProfileFormatItemDragEnd: PropTypes.func.isRequired
};
export default DropTarget(
QUALITY_PROFILE_FORMAT_ITEM,
qualityProfileFormatItemDropTarget,
collectDropTarget
)(DragSource(
QUALITY_PROFILE_FORMAT_ITEM,
qualityProfileFormatItemDragSource,
collectDragSource
)(QualityProfileFormatItemDragSource));

View File

@ -0,0 +1,6 @@
.formats {
margin-top: 10px;
/* TODO: This should consider the number of languages in the list */
min-height: 550px;
user-select: none;
}

View File

@ -0,0 +1,103 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import QualityProfileFormatItemDragSource from './QualityProfileFormatItemDragSource';
import QualityProfileFormatItemDragPreview from './QualityProfileFormatItemDragPreview';
import styles from './QualityProfileFormatItems.css';
class QualityProfileFormatItems extends Component {
//
// Render
render() {
const {
dragIndex,
dropIndex,
profileFormatItems,
errors,
warnings,
...otherProps
} = this.props;
const isDragging = dropIndex !== null;
const isDraggingUp = isDragging && dropIndex > dragIndex;
const isDraggingDown = isDragging && dropIndex < dragIndex;
return (
<FormGroup>
<FormLabel>Custom Formats</FormLabel>
<div>
<FormInputHelpText
text="Custom Formats higher in the list are more preferred. Only checked custom formats are wanted"
/>
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={false}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={false}
/>
);
})
}
<div className={styles.formats}>
{
profileFormatItems.map(({ allowed, format }, index) => {
return (
<QualityProfileFormatItemDragSource
key={format.id}
formatId={format.id}
name={format.name}
allowed={allowed}
sortIndex={index}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
{...otherProps}
/>
);
}).reverse()
}
<QualityProfileFormatItemDragPreview />
</div>
</div>
</FormGroup>
);
}
}
QualityProfileFormatItems.propTypes = {
dragIndex: PropTypes.number,
dropIndex: PropTypes.number,
profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object)
};
QualityProfileFormatItems.defaultProps = {
errors: [],
warnings: []
};
export default QualityProfileFormatItems;

View File

@ -6,6 +6,7 @@
"build": "gulp build",
"start": "gulp watch",
"watch": "gulp watch",
"clean": "git clean -fXd",
"lint": "esprint check",
"lint-fix": "eslint start --fix",
"stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc"
@ -53,7 +54,7 @@
"eslint": "6.0.1",
"eslint-plugin-filenames": "1.3.2",
"eslint-plugin-react": "7.14.2",
"esprint": "0.4.0",
"esprint": "0.5.0",
"file-loader": "4.0.0",
"filesize": "4.1.2",
"fuse.js": "3.4.5",

View File

@ -51,6 +51,17 @@ namespace NzbDrone.Api.Profiles
? cutoffItem.Quality
: cutoffItem.Items.First().Quality;
var formatCutoffItem = model.FormatItems.First(q =>
{
if (q.Id == model.FormatCutoff) return true;
if (q.Format == null) return false;
return q.Format.Id == model.FormatCutoff;
});
var formatCutoff = formatCutoffItem.Format;
return new ProfileResource
{
Id = model.Id,
@ -74,7 +85,7 @@ namespace NzbDrone.Api.Profiles
return new List<ProfileQualityItemResource> { ToResource(i) };
}).ToList(),
FormatCutoff = model.FormatCutoff.ToResource(),
FormatCutoff = formatCutoff.ToResource(),
FormatItems = model.FormatItems.ConvertAll(ToResource),
Language = model.Language
};
@ -112,7 +123,7 @@ namespace NzbDrone.Api.Profiles
Cutoff = resource.Cutoff.Id,
PreferredTags = resource.PreferredTags.Split(',').ToList(),
Items = resource.Items.ConvertAll(ToModel),
FormatCutoff = resource.FormatCutoff.ToModel(),
FormatCutoff = resource.FormatCutoff.ToModel().Id,
FormatItems = resource.FormatItems.ConvertAll(ToModel),
Language = resource.Language
};

View File

@ -45,7 +45,7 @@ namespace NzbDrone.Api.Profiles
var profile = new Profile();
profile.Cutoff = Quality.Unknown.Id;
profile.Items = items;
profile.FormatCutoff = CustomFormat.None;
profile.FormatCutoff = CustomFormat.None.Id;
profile.FormatItems = formatItems;
profile.Language = Language.English;

View File

@ -79,7 +79,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{
Cutoff = Quality.HDTV720p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
FormatCutoff = CustomFormats.CustomFormat.None,
FormatCutoff = CustomFormats.CustomFormat.None.Id,
FormatItems = CustomFormatsFixture.GetSampleFormatItems("None", "My Format")
}, old, newQ).Should().BeFalse();
}

View File

@ -24,7 +24,7 @@ namespace NzbDrone.Core.Test.MovieTests.MovieRepositoryTests
{
Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p),
FormatItems = CustomFormat.CustomFormatsFixture.GetDefaultFormatItems(),
FormatCutoff = CustomFormats.CustomFormat.None,
FormatCutoff = CustomFormats.CustomFormat.None.Id,
Cutoff = Quality.Bluray1080p.Id,
Name = "TestProfile"
};

View File

@ -20,7 +20,7 @@ namespace NzbDrone.Core.Test.Profiles
var profile = new Profile
{
Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p),
FormatCutoff = CustomFormats.CustomFormat.None,
FormatCutoff = CustomFormats.CustomFormat.None.Id,
FormatItems = CustomFormat.CustomFormatsFixture.GetDefaultFormatItems(),
Cutoff = Quality.Bluray1080p.Id,
Name = "TestProfile"

View File

@ -7,6 +7,8 @@ using NzbDrone.Core.Profiles;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Movies;
using NzbDrone.Core.NetImport;
using NzbDrone.Core.CustomFormats;
using System.Collections.Generic;
namespace NzbDrone.Core.Test.Profiles
{
@ -17,6 +19,10 @@ namespace NzbDrone.Core.Test.Profiles
[Test]
public void init_should_add_default_profiles()
{
Mocker.GetMock<ICustomFormatService>()
.Setup(s => s.All())
.Returns(new List<CustomFormats.CustomFormat>());
Subject.Handle(new ApplicationStartedEvent());
Mocker.GetMock<IProfileRepository>()

View File

@ -17,7 +17,7 @@ namespace NzbDrone.Core.Profiles
public string Name { get; set; }
public int Cutoff { get; set; }
public List<ProfileQualityItem> Items { get; set; }
public CustomFormat FormatCutoff { get; set; }
public int FormatCutoff { get; set; }
public List<ProfileFormatItem> FormatItems { get; set; }
public List<string> PreferredTags { get; set; }
public Language Language { get; set; }

View File

@ -1,10 +1,13 @@
using NzbDrone.Core.CustomFormats;
using Newtonsoft.Json;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Profiles
{
public class ProfileFormatItem : IEmbeddedDocument
{
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public int Id { get; set; }
public CustomFormat Format { get; set; }
public bool Allowed { get; set; }
}

View File

@ -74,9 +74,9 @@ namespace NzbDrone.Core.Profiles
foreach (var profile in all)
{
profile.FormatItems = profile.FormatItems.Where(c => c.Format.Id != formatId).ToList();
if (profile.FormatCutoff.Id == formatId)
if (profile.FormatCutoff == formatId)
{
profile.FormatCutoff = CustomFormat.None;
profile.FormatCutoff = CustomFormat.None.Id;
}
Update(profile);
@ -187,7 +187,9 @@ namespace NzbDrone.Core.Profiles
public Profile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed)
{
var groupedQualites = Quality.DefaultQualityDefinitions.GroupBy(q => q.Weight);
var formats = _formatService.All();
var items = new List<ProfileQualityItem>();
var formatItems = new List<ProfileFormatItem>();
var groupId = 1000;
var profileCutoff = cutoff == null ? Quality.Unknown.Id : cutoff.Id;
@ -223,23 +225,36 @@ namespace NzbDrone.Core.Profiles
groupId++;
}
foreach (var format in formats)
{
formatItems.Add(new ProfileFormatItem
{
Id = format.Id,
Format = format,
Allowed = false
});
}
var qualityProfile = new Profile
{
Name = name,
Cutoff = profileCutoff,
Items = items,
Language = Language.English,
FormatCutoff = CustomFormat.None,
FormatCutoff = CustomFormat.None.Id,
FormatItems = new List<ProfileFormatItem>
{
new ProfileFormatItem
{
Id = 0,
Allowed = true,
Format = CustomFormat.None
}
}
};
qualityProfile.FormatItems.AddRange(formatItems);
return qualityProfile;
}

View File

@ -94,5 +94,15 @@ namespace NzbDrone.Core.Qualities
return leftIndicies.Select(i => i.CompareTo(rightIndex)).Sum();
}
public int Compare(List<CustomFormat> left, int right)
{
left = left.WithNone();
var leftIndicies = GetIndicies(left, _profile);
var rightIndex = _profile.FormatItems.FindIndex(v => Equals(v.Format, right));
return leftIndicies.Select(i => i.CompareTo(rightIndex)).Sum();
}
}
}

View File

@ -33,7 +33,7 @@ namespace Radarr.Api.V2.Profiles.Quality
return all.Except(ids).Empty();
}).WithMessage("All Custom Formats and no extra ones need to be present inside your Profile! Try refreshing your browser.");
SharedValidator.RuleFor(c => c.FormatCutoff)
.Must(c => _formatService.All().Select(f => f.Id).Contains(c.Id) || c.Id == CustomFormat.None.Id).WithMessage("The Custom Format Cutoff must be a valid Custom Format! Try refreshing your browser.");
.Must(c => _formatService.All().Select(f => f.Id).Contains(c) || c == CustomFormat.None.Id).WithMessage("The Custom Format Cutoff must be a valid Custom Format! Try refreshing your browser.");
GetResourceAll = GetAll;
GetResourceById = GetById;

View File

@ -6,6 +6,7 @@ using NzbDrone.Core.Parser;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Languages;
using NzbDrone.Core.CustomFormats;
namespace Radarr.Api.V2.Profiles.Quality
{
@ -16,7 +17,7 @@ namespace Radarr.Api.V2.Profiles.Quality
public int Cutoff { get; set; }
public string PreferredTags { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; }
public CustomFormatResource FormatCutoff { get; set; }
public int FormatCutoff { get; set; }
public List<ProfileFormatItemResource> FormatItems { get; set; }
public Language Language { get; set; }
}
@ -36,7 +37,7 @@ namespace Radarr.Api.V2.Profiles.Quality
public class ProfileFormatItemResource : RestResource
{
public CustomFormatResource Format { get; set; }
public CustomFormat Format { get; set; }
public bool Allowed { get; set; }
}
@ -54,7 +55,7 @@ namespace Radarr.Api.V2.Profiles.Quality
Cutoff = model.Cutoff,
PreferredTags = model.PreferredTags != null ? string.Join(",", model.PreferredTags) : "",
Items = model.Items.ConvertAll(ToResource),
FormatCutoff = model.FormatCutoff.ToResource(),
FormatCutoff = model.FormatCutoff,
FormatItems = model.FormatItems.ConvertAll(ToResource),
Language = model.Language
};
@ -78,7 +79,7 @@ namespace Radarr.Api.V2.Profiles.Quality
{
return new ProfileFormatItemResource
{
Format = model.Format.ToResource(),
Format = model.Format,
Allowed = model.Allowed
};
}
@ -95,7 +96,7 @@ namespace Radarr.Api.V2.Profiles.Quality
Cutoff = resource.Cutoff,
PreferredTags = resource.PreferredTags.Split(',').ToList(),
Items = resource.Items.ConvertAll(ToModel),
FormatCutoff = resource.FormatCutoff.ToModel(),
FormatCutoff = resource.FormatCutoff,
FormatItems = resource.FormatItems.ConvertAll(ToModel),
Language = resource.Language
};
@ -119,7 +120,7 @@ namespace Radarr.Api.V2.Profiles.Quality
{
return new ProfileFormatItem
{
Format = resource.Format.ToModel(),
Format = resource.Format,
Allowed = resource.Allowed
};
}

View File

@ -30,7 +30,7 @@ namespace Radarr.Api.V2.Qualities
return !allFormats.Any(f =>
{
var allTags = f.FormatTags.Select(t => t.Raw.ToLower());
var allNewTags = c.Select(t => t.ToLower());
var allNewTags = c.Split(',').Select(t => t.ToLower());
var enumerable = allTags.ToList();
var newTags = allNewTags.ToList();
return (enumerable.All(newTags.Contains) && f.Id != v.Id && enumerable.Count() == newTags.Count());

View File

@ -8,7 +8,7 @@ namespace Radarr.Api.V2.Qualities
public class CustomFormatResource : RestResource
{
public string Name { get; set; }
public List<string> FormatTags { get; set; }
public string FormatTags { get; set; }
public string Simplicity { get; set; }
}
@ -20,7 +20,7 @@ namespace Radarr.Api.V2.Qualities
{
Id = model.Id,
Name = model.Name,
FormatTags = model.FormatTags.Select(t => t.Raw.ToUpper()).ToList(),
FormatTags = string.Join(",", model.FormatTags.Select(t => t.Raw.ToUpper()).ToList()),
};
}
@ -30,7 +30,7 @@ namespace Radarr.Api.V2.Qualities
{
Id = resource.Id,
Name = resource.Name,
FormatTags = resource.FormatTags.Select(s => new FormatTag(s)).ToList(),
FormatTags = resource.FormatTags.Split(',').Select(s => new FormatTag(s)).ToList()
};
}

View File

@ -19,7 +19,7 @@ namespace Radarr.Api.V2.Qualities
return false;
}
var tags = (IEnumerable<string>) context.PropertyValue;
var tags = (IEnumerable<string>) context.PropertyValue.ToString().Split(',');
var invalidTags = tags.Where(t => !FormatTag.QualityTagRegex.IsMatch(t));

View File

@ -3295,10 +3295,10 @@ esprima@^4.0.0:
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esprint@0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/esprint/-/esprint-0.4.0.tgz#f89c9bace36d90407968a8f9ceb0800ff786aab0"
integrity sha1-+JybrONtkEB5aKj5zrCAD/eGqrA=
esprint@0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/esprint/-/esprint-0.5.0.tgz#25975b855b9df625ce2e32655db6dff1a84bbe36"
integrity sha512-TpaXKPy6g1saDqMYwqppZC6C0wQpYQAnhms6829oVvP6XieUbGjQdcNgatGQMihin2bMgE90tmX+1OOPc5tuiw==
dependencies:
dnode "^1.2.2"
fb-watchman "^2.0.0"