Compare commits

...

3 Commits

Author SHA1 Message Date
Mark McDowall f296fe7c6b fixup! New: Quality limits are part of Quality Profile 2024-06-11 20:28:18 -07:00
Mark McDowall ee84835d52 fixup! New: Quality limits are part of Quality Profile 2024-06-11 18:47:05 -07:00
Mark McDowall 20e68c1d3c New: Quality limits are part of Quality Profile
Closes #613
2024-06-11 17:03:38 -07:00
48 changed files with 1172 additions and 879 deletions

View File

@ -43,6 +43,14 @@ export interface AppSectionItemState<T> {
item: T; item: T;
} }
export interface AppSectionListState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: T[];
pendingChanges: Partial<T>[];
}
interface AppSectionState<T> { interface AppSectionState<T> {
isFetching: boolean; isFetching: boolean;
isPopulated: boolean; isPopulated: boolean;

View File

@ -1,6 +1,7 @@
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionItemState, AppSectionItemState,
AppSectionListState,
AppSectionSaveState, AppSectionSaveState,
AppSectionSchemaState, AppSectionSchemaState,
PagedAppSectionState, PagedAppSectionState,
@ -13,6 +14,7 @@ import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
import Indexer from 'typings/Indexer'; import Indexer from 'typings/Indexer';
import IndexerFlag from 'typings/IndexerFlag'; import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification'; import Notification from 'typings/Notification';
import QualityDefinition from 'typings/QualityDefinition';
import QualityProfile from 'typings/QualityProfile'; import QualityProfile from 'typings/QualityProfile';
import { UiSettings } from 'typings/UiSettings'; import { UiSettings } from 'typings/UiSettings';
@ -35,6 +37,10 @@ export interface NotificationAppState
extends AppSectionState<Notification>, extends AppSectionState<Notification>,
AppSectionDeleteState {} AppSectionDeleteState {}
export interface QualityDefinitionsAppState
extends AppSectionListState<QualityDefinition>,
AppSectionSaveState {}
export interface QualityProfilesAppState export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>, extends AppSectionState<QualityProfile>,
AppSectionSchemaState<QualityProfile> {} AppSectionSchemaState<QualityProfile> {}
@ -65,6 +71,7 @@ interface SettingsAppState {
indexers: IndexerAppState; indexers: IndexerAppState;
languages: LanguageSettingsAppState; languages: LanguageSettingsAppState;
notifications: NotificationAppState; notifications: NotificationAppState;
qualityDefinitions: QualityDefinitionsAppState;
qualityProfiles: QualityProfilesAppState; qualityProfiles: QualityProfilesAppState;
ui: UiSettingsAppState; ui: UiSettingsAppState;
} }

View File

@ -21,7 +21,7 @@ class EditQualityProfileModal extends Component {
// Listeners // Listeners
onContentHeightChange = (height) => { onContentHeightChange = (height) => {
if (this.state.height === 'auto' || height > this.state.height) { if (this.state.height === 'auto' || height !== 0) {
this.setState({ height }); this.setState({ height });
} }
}; };

View File

@ -44,6 +44,9 @@ class EditQualityProfileModalContent extends Component {
this.state = { this.state = {
headerHeight: 0, headerHeight: 0,
bodyHeight: 0, bodyHeight: 0,
defaultBodyHeight: 0,
editGroupsBodyHeight: 0,
editSizesBodyHeight: 0,
footerHeight: 0 footerHeight: 0
}; };
} }
@ -51,17 +54,18 @@ class EditQualityProfileModalContent extends Component {
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const { const {
headerHeight, headerHeight,
bodyHeight,
footerHeight footerHeight
} = this.state; } = this.state;
const bodyHeight = this.state[`${this.props.mode}BodyHeight`];
if ( if (
headerHeight > 0 && headerHeight > 0 &&
bodyHeight > 0 && bodyHeight > 0 &&
footerHeight > 0 && footerHeight > 0 &&
( (
headerHeight !== prevState.headerHeight || headerHeight !== prevState.headerHeight ||
bodyHeight !== prevState.bodyHeight || bodyHeight !== prevState[`${prevProps.mode}BodyHeight`] ||
footerHeight !== prevState.footerHeight footerHeight !== prevState.footerHeight
) )
) { ) {
@ -77,15 +81,16 @@ class EditQualityProfileModalContent extends Component {
// Listeners // Listeners
onHeaderMeasure = ({ height }) => { onHeaderMeasure = ({ height }) => {
if (height > this.state.headerHeight) { if (height !== this.state.headerHeight) {
this.setState({ headerHeight: height }); this.setState({ headerHeight: height });
} }
}; };
onBodyMeasure = ({ height }) => { onBodyMeasure = ({ height }) => {
const heightKey = `${this.props.mode}BodyHeight`;
if (height > this.state.bodyHeight) { if (height !== this.state[heightKey]) {
this.setState({ bodyHeight: height }); this.setState({ [heightKey]: height });
} }
}; };
@ -100,7 +105,7 @@ class EditQualityProfileModalContent extends Component {
render() { render() {
const { const {
editGroups, mode,
isFetching, isFetching,
error, error,
isSaving, isSaving,
@ -251,7 +256,7 @@ class EditQualityProfileModalContent extends Component {
<div className={styles.formGroupWrapper}> <div className={styles.formGroupWrapper}>
<QualityProfileItems <QualityProfileItems
editGroups={editGroups} mode={mode}
qualityProfileItems={items.value} qualityProfileItems={items.value}
errors={items.errors} errors={items.errors}
warnings={items.warnings} warnings={items.warnings}
@ -318,7 +323,7 @@ class EditQualityProfileModalContent extends Component {
} }
EditQualityProfileModalContent.propTypes = { EditQualityProfileModalContent.propTypes = {
editGroups: PropTypes.bool.isRequired, mode: PropTypes.string.isRequired,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,

View File

@ -126,7 +126,7 @@ class EditQualityProfileModalContentConnector extends Component {
dragQualityIndex: null, dragQualityIndex: null,
dropQualityIndex: null, dropQualityIndex: null,
dropPosition: null, dropPosition: null,
editGroups: false mode: 'default' // default, editGroups, editSizes
}; };
} }
@ -256,6 +256,49 @@ class EditQualityProfileModalContentConnector extends Component {
}); });
}; };
onSizeChange = ({ id, minSize, maxSize, preferredSize }) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
let quality = null;
// eslint-disable-next-line guard-for-in
for (const index in items) {
const item = items[index];
if (item.quality?.id === id) {
quality = item;
break;
}
// eslint-disable-next-line guard-for-in
for (const i in item.items) {
const nestedItem = items[i];
if (nestedItem.quality?.id === id) {
quality = nestedItem;
break;
}
}
if (quality) {
break;
}
}
if (!quality) {
return;
}
quality.minSize = minSize;
quality.maxSize = maxSize;
quality.preferredSize = preferredSize;
this.props.setQualityProfileValue({
name: 'items',
value: items
});
};
onCreateGroupPress = (id) => { onCreateGroupPress = (id) => {
const qualityProfile = _.cloneDeep(this.props.item); const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value; const items = qualityProfile.items.value;
@ -439,8 +482,8 @@ class EditQualityProfileModalContentConnector extends Component {
}); });
}; };
onToggleEditGroupsMode = () => { onChangeMode = (mode) => {
this.setState({ editGroups: !this.state.editGroups }); this.setState({ mode });
}; };
// //
@ -466,7 +509,8 @@ class EditQualityProfileModalContentConnector extends Component {
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove} onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd} onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onQualityProfileFormatItemScoreChange={this.onQualityProfileFormatItemScoreChange} onQualityProfileFormatItemScoreChange={this.onQualityProfileFormatItemScoreChange}
onToggleEditGroupsMode={this.onToggleEditGroupsMode} onChangeMode={this.onChangeMode}
onSizeChange={this.onSizeChange}
/> />
); );
} }

View File

@ -9,6 +9,11 @@
&.isInGroup { &.isInGroup {
border-style: dashed; border-style: dashed;
} }
&.editSizes {
flex-direction: column;
padding: 10px;
}
} }
.checkInputContainer { .checkInputContainer {
@ -32,6 +37,10 @@
font-weight: normal; font-weight: normal;
line-height: $qualityProfileItemHeight; line-height: $qualityProfileItemHeight;
cursor: pointer; cursor: pointer;
&.editSizes {
cursor: default;
}
} }
.qualityName { .qualityName {

View File

@ -6,6 +6,7 @@ interface CssExports {
'createGroupButton': string; 'createGroupButton': string;
'dragHandle': string; 'dragHandle': string;
'dragIcon': string; 'dragIcon': string;
'editSizes': string;
'isDragging': string; 'isDragging': string;
'isInGroup': string; 'isInGroup': string;
'isPreview': string; 'isPreview': string;

View File

@ -6,6 +6,7 @@ import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import QualityProfileItemSize from './QualityProfileItemSize';
import styles from './QualityProfileItem.css'; import styles from './QualityProfileItem.css';
class QualityProfileItem extends Component { class QualityProfileItem extends Component {
@ -36,20 +37,26 @@ class QualityProfileItem extends Component {
render() { render() {
const { const {
editGroups, mode,
isPreview, isPreview,
qualityId,
groupId, groupId,
name, name,
allowed, allowed,
minSize,
maxSize,
preferredSize,
isDragging, isDragging,
isOverCurrent, isOverCurrent,
connectDragSource connectDragSource,
onSizeChange
} = this.props; } = this.props;
return ( return (
<div <div
className={classNames( className={classNames(
styles.qualityProfileItem, styles.qualityProfileItem,
mode === 'editSizes' && styles.editSizes,
isDragging && styles.isDragging, isDragging && styles.isDragging,
isPreview && styles.isPreview, isPreview && styles.isPreview,
isOverCurrent && styles.isOverCurrent, isOverCurrent && styles.isOverCurrent,
@ -57,10 +64,13 @@ class QualityProfileItem extends Component {
)} )}
> >
<label <label
className={styles.qualityNameContainer} className={classNames(
styles.qualityNameContainer,
mode === 'editSizes' && styles.editSizes
)}
> >
{ {
editGroups && !groupId && !isPreview && mode === 'editGroups' && !groupId && !isPreview &&
<IconButton <IconButton
className={styles.createGroupButton} className={styles.createGroupButton}
name={icons.GROUP} name={icons.GROUP}
@ -70,7 +80,7 @@ class QualityProfileItem extends Component {
} }
{ {
!editGroups && mode === 'default' &&
<CheckInput <CheckInput
className={styles.checkInput} className={styles.checkInput}
containerClassName={styles.checkInputContainer} containerClassName={styles.checkInputContainer}
@ -83,7 +93,7 @@ class QualityProfileItem extends Component {
<div className={classNames( <div className={classNames(
styles.qualityName, styles.qualityName,
groupId && styles.isInGroup, groupId && mode !== 'editSizes' && styles.isInGroup,
!allowed && styles.notAllowed !allowed && styles.notAllowed
)} )}
> >
@ -92,6 +102,21 @@ class QualityProfileItem extends Component {
</label> </label>
{ {
mode === 'editSizes' && qualityId != null ?
<div>
<QualityProfileItemSize
id={qualityId}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
onSizeChange={onSizeChange}
/>
</div> :
null
}
{
mode === 'editSizes' ? null :
connectDragSource( connectDragSource(
<div className={styles.dragHandle}> <div className={styles.dragHandle}>
<Icon <Icon
@ -108,21 +133,26 @@ class QualityProfileItem extends Component {
} }
QualityProfileItem.propTypes = { QualityProfileItem.propTypes = {
editGroups: PropTypes.bool, mode: PropTypes.string.isRequired,
isPreview: PropTypes.bool, isPreview: PropTypes.bool,
groupId: PropTypes.number, groupId: PropTypes.number,
qualityId: PropTypes.number.isRequired, qualityId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired, allowed: PropTypes.bool.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
isDragging: PropTypes.bool.isRequired, isDragging: PropTypes.bool.isRequired,
isOverCurrent: PropTypes.bool.isRequired, isOverCurrent: PropTypes.bool.isRequired,
isInGroup: PropTypes.bool, isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func, connectDragSource: PropTypes.func,
onCreateGroupPress: PropTypes.func, onCreateGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func onQualityProfileItemAllowedChange: PropTypes.func,
onSizeChange: PropTypes.func
}; };
QualityProfileItem.defaultProps = { QualityProfileItem.defaultProps = {
mode: 'default',
isPreview: false, isPreview: false,
isOverCurrent: false, isOverCurrent: false,
// The drag preview will not connect the drag handle. // The drag preview will not connect the drag handle.

View File

@ -11,7 +11,7 @@ import styles from './QualityProfileItemDragSource.css';
const qualityProfileItemDragSource = { const qualityProfileItemDragSource = {
beginDrag(props) { beginDrag(props) {
const { const {
editGroups, mode,
qualityIndex, qualityIndex,
groupId, groupId,
qualityId, qualityId,
@ -20,7 +20,7 @@ const qualityProfileItemDragSource = {
} = props; } = props;
return { return {
editGroups, mode,
qualityIndex, qualityIndex,
groupId, groupId,
qualityId, qualityId,
@ -110,12 +110,15 @@ class QualityProfileItemDragSource extends Component {
render() { render() {
const { const {
editGroups, mode,
groupId, groupId,
qualityId, qualityId,
name, name,
allowed, allowed,
items, items,
minSize,
maxSize,
preferredSize,
qualityIndex, qualityIndex,
isDragging, isDragging,
isDraggingUp, isDraggingUp,
@ -129,7 +132,8 @@ class QualityProfileItemDragSource extends Component {
onItemGroupAllowedChange, onItemGroupAllowedChange,
onItemGroupNameChange, onItemGroupNameChange,
onQualityProfileItemDragMove, onQualityProfileItemDragMove,
onQualityProfileItemDragEnd onQualityProfileItemDragEnd,
onSizeChange
} = this.props; } = this.props;
const isBefore = !isDragging && isDraggingUp && isOverCurrent; const isBefore = !isDragging && isDraggingUp && isOverCurrent;
@ -156,7 +160,7 @@ class QualityProfileItemDragSource extends Component {
{ {
!!groupId && qualityId == null && !!groupId && qualityId == null &&
<QualityProfileItemGroup <QualityProfileItemGroup
editGroups={editGroups} mode={mode}
groupId={groupId} groupId={groupId}
name={name} name={name}
allowed={allowed} allowed={allowed}
@ -172,23 +176,28 @@ class QualityProfileItemDragSource extends Component {
onItemGroupNameChange={onItemGroupNameChange} onItemGroupNameChange={onItemGroupNameChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove} onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd} onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
onSizeChange={onSizeChange}
/> />
} }
{ {
qualityId != null && qualityId != null &&
<QualityProfileItem <QualityProfileItem
editGroups={editGroups} mode={mode}
groupId={groupId} groupId={groupId}
qualityId={qualityId} qualityId={qualityId}
name={name} name={name}
allowed={allowed} allowed={allowed}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
qualityIndex={qualityIndex} qualityIndex={qualityIndex}
isDragging={isDragging} isDragging={isDragging}
isOverCurrent={isOverCurrent} isOverCurrent={isOverCurrent}
connectDragSource={connectDragSource} connectDragSource={connectDragSource}
onCreateGroupPress={onCreateGroupPress} onCreateGroupPress={onCreateGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange} onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onSizeChange={onSizeChange}
/> />
} }
@ -207,12 +216,15 @@ class QualityProfileItemDragSource extends Component {
} }
QualityProfileItemDragSource.propTypes = { QualityProfileItemDragSource.propTypes = {
editGroups: PropTypes.bool.isRequired, mode: PropTypes.string.isRequired,
groupId: PropTypes.number, groupId: PropTypes.number,
qualityId: PropTypes.number, qualityId: PropTypes.number,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired, allowed: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object), items: PropTypes.arrayOf(PropTypes.object),
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
qualityIndex: PropTypes.string.isRequired, qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool, isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool, isDraggingUp: PropTypes.bool,
@ -227,7 +239,8 @@ QualityProfileItemDragSource.propTypes = {
onItemGroupAllowedChange: PropTypes.func, onItemGroupAllowedChange: PropTypes.func,
onItemGroupNameChange: PropTypes.func, onItemGroupNameChange: PropTypes.func,
onQualityProfileItemDragMove: PropTypes.func.isRequired, onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired onQualityProfileItemDragEnd: PropTypes.func.isRequired,
onSizeChange: PropTypes.func.isRequired
}; };
export default DropTarget( export default DropTarget(

View File

@ -7,6 +7,10 @@
&.editGroups { &.editGroups {
background: var(--inputBackgroundColor); background: var(--inputBackgroundColor);
} }
&.editSizes {
padding: 10px;
}
} }
.qualityProfileItemGroupInfo { .qualityProfileItemGroupInfo {
@ -70,6 +74,10 @@
cursor: pointer; cursor: pointer;
} }
.editSizesQualityNameLabel {
composes: qualityNameContainer;
}
.deleteGroupButton { .deleteGroupButton {
composes: buton from '~Components/Link/IconButton.css'; composes: buton from '~Components/Link/IconButton.css';

View File

@ -7,6 +7,8 @@ interface CssExports {
'dragHandle': string; 'dragHandle': string;
'dragIcon': string; 'dragIcon': string;
'editGroups': string; 'editGroups': string;
'editSizes': string;
'editSizesQualityNameLabel': string;
'groupQualities': string; 'groupQualities': string;
'isDragging': string; 'isDragging': string;
'items': string; 'items': string;

View File

@ -48,7 +48,7 @@ class QualityProfileItemGroup extends Component {
render() { render() {
const { const {
editGroups, mode,
groupId, groupId,
name, name,
allowed, allowed,
@ -60,20 +60,22 @@ class QualityProfileItemGroup extends Component {
connectDragSource, connectDragSource,
onQualityProfileItemAllowedChange, onQualityProfileItemAllowedChange,
onQualityProfileItemDragMove, onQualityProfileItemDragMove,
onQualityProfileItemDragEnd onQualityProfileItemDragEnd,
onSizeChange
} = this.props; } = this.props;
return ( return (
<div <div
className={classNames( className={classNames(
styles.qualityProfileItemGroup, styles.qualityProfileItemGroup,
editGroups && styles.editGroups, mode === 'editGroups' && styles.editGroups,
mode === 'editSizes' && styles.editSizes,
isDragging && styles.isDragging isDragging && styles.isDragging
)} )}
> >
<div className={styles.qualityProfileItemGroupInfo}> <div className={styles.qualityProfileItemGroupInfo}>
{ {
editGroups && mode === 'editGroups' &&
<div className={styles.qualityNameContainer}> <div className={styles.qualityNameContainer}>
<IconButton <IconButton
className={styles.deleteGroupButton} className={styles.deleteGroupButton}
@ -92,7 +94,7 @@ class QualityProfileItemGroup extends Component {
} }
{ {
!editGroups && mode === 'default' &&
<label <label
className={styles.qualityNameLabel} className={styles.qualityNameLabel}
> >
@ -129,6 +131,24 @@ class QualityProfileItemGroup extends Component {
} }
{ {
mode === 'editSizes' &&
<label
className={styles.editSizesQualityNameLabel}
>
<div className={styles.nameContainer}>
<div className={classNames(
styles.name,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</div>
</label>
}
{
mode === 'editSizes' ? null :
connectDragSource( connectDragSource(
<div className={styles.dragHandle}> <div className={styles.dragHandle}>
<Icon <Icon
@ -142,18 +162,22 @@ class QualityProfileItemGroup extends Component {
</div> </div>
{ {
editGroups && mode === 'default' ?
<div className={styles.items}> null :
<div className={mode === 'editGroups' ? styles.items : undefined}>
{ {
items.map(({ quality }, index) => { items.map(({ quality }, index) => {
return ( return (
<QualityProfileItemDragSource <QualityProfileItemDragSource
key={quality.id} key={quality.id}
editGroups={editGroups} mode={mode}
groupId={groupId} groupId={groupId}
qualityId={quality.id} qualityId={quality.id}
name={quality.name} name={quality.name}
allowed={allowed} allowed={allowed}
minSize={quality.minSize}
maxSize={quality.maxSize}
preferredSize={quality.preferredSize}
items={items} items={items}
qualityIndex={`${qualityIndex}.${index + 1}`} qualityIndex={`${qualityIndex}.${index + 1}`}
isDragging={isDragging} isDragging={isDragging}
@ -163,6 +187,7 @@ class QualityProfileItemGroup extends Component {
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange} onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove} onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd} onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
onSizeChange={onSizeChange}
/> />
); );
}).reverse() }).reverse()
@ -175,7 +200,7 @@ class QualityProfileItemGroup extends Component {
} }
QualityProfileItemGroup.propTypes = { QualityProfileItemGroup.propTypes = {
editGroups: PropTypes.bool, mode: PropTypes.string.isRequired,
groupId: PropTypes.number.isRequired, groupId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired, allowed: PropTypes.bool.isRequired,
@ -190,10 +215,12 @@ QualityProfileItemGroup.propTypes = {
onItemGroupNameChange: PropTypes.func.isRequired, onItemGroupNameChange: PropTypes.func.isRequired,
onDeleteGroupPress: PropTypes.func.isRequired, onDeleteGroupPress: PropTypes.func.isRequired,
onQualityProfileItemDragMove: PropTypes.func.isRequired, onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired onQualityProfileItemDragEnd: PropTypes.func.isRequired,
onSizeChange: PropTypes.func
}; };
QualityProfileItemGroup.defaultProps = { QualityProfileItemGroup.defaultProps = {
mode: 'default',
// The drag preview will not connect the drag handle. // The drag preview will not connect the drag handle.
connectDragSource: (node) => node connectDragSource: (node) => node
}; };

View File

@ -0,0 +1,60 @@
.sizeLimit {
flex: 0 1 500px;
}
.slider {
width: 100%;
height: 20px;
}
.track {
top: 9px;
margin: 0 5px;
height: 3px;
background-color: var(--sliderAccentColor);
box-shadow: 0 0 0 #000;
&:nth-child(3n + 1) {
background-color: #ddd;
}
}
.thumb {
top: 1px;
z-index: 0 !important;
width: 18px;
height: 18px;
border: 3px solid var(--sliderAccentColor);
border-radius: 50%;
background-color: var(--white);
text-align: center;
cursor: pointer;
}
.sizes {
display: flex;
justify-content: space-between;
}
.megabytesPerMinuteContainer {
display: flex;
justify-content: space-between;
flex: 0 0 400px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--borderColor);
}
.megabytesPerMinute {
display: flex;
flex-direction: column;
}
.sizeInput {
composes: input from '~Components/Form/TextInput.css';
display: inline-block;
flex-grow: 1;
margin-bottom: 2px;
padding: 6px;
}

View File

@ -0,0 +1,14 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'megabytesPerMinute': string;
'megabytesPerMinuteContainer': string;
'sizeInput': string;
'sizeLimit': string;
'sizes': string;
'slider': string;
'thumb': string;
'track': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,291 @@
import React, { HTMLProps, useCallback, useState } from 'react';
import ReactSlider from 'react-slider';
import NumberInput from 'Components/Form/NumberInput';
import Label from 'Components/Label';
import Popover from 'Components/Tooltip/Popover';
import { kinds, tooltipPositions } from 'Helpers/Props';
import QualityDefinitionLimits from 'Settings/Quality/Definition/QualityDefinitionLimits';
import formatBytes from 'Utilities/Number/formatBytes';
import roundNumber from 'Utilities/Number/roundNumber';
import translate from 'Utilities/String/translate';
import styles from './QualityProfileItemSize.css';
const MIN = 0;
const MAX = 400;
const STEP_SIZE = 0.1;
const MIN_DISTANCE = 3;
const SLIDER_MAX = roundNumber(Math.pow(MAX, 1 / 1.1));
interface SizeProps {
minSize: number | null;
preferredSize: number | null;
maxSize: number | null;
}
export interface OnSizeChangeArguments extends SizeProps {
id: number;
}
export interface QualityProfileItemSizeProps extends OnSizeChangeArguments {
onSizeChange: (props: OnSizeChangeArguments) => void;
}
function trackRenderer(props: HTMLProps<HTMLDivElement>) {
return <div {...props} className={styles.track} />;
}
function thumbRenderer(props: HTMLProps<HTMLDivElement>) {
return <div {...props} className={styles.thumb} />;
}
function getSliderValue(value: number | null, defaultValue: number): number {
const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue;
return roundNumber(sliderValue);
}
export default function QualityProfileItemSize(
props: QualityProfileItemSizeProps
) {
const { id, minSize, maxSize, preferredSize, onSizeChange } = props;
const [sizes, setSizes] = useState<SizeProps>({
minSize: getSliderValue(minSize, MIN),
preferredSize: getSliderValue(preferredSize, SLIDER_MAX - MIN_DISTANCE),
maxSize: getSliderValue(maxSize, SLIDER_MAX),
});
const handleSliderChange = useCallback(
([sliderMinSize, sliderPreferredSize, sliderMaxSize]: [
number,
number,
number
]) => {
// console.log('Sizes:', sliderMinSize, sliderPreferredSize, sliderMaxSize);
console.log(
'Min Sizes: ',
sliderMinSize,
roundNumber(Math.pow(sliderMinSize, 1.1))
);
setSizes({
minSize: sliderMinSize,
preferredSize: sliderPreferredSize,
maxSize: sliderMaxSize,
});
onSizeChange({
id,
minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
preferredSize:
sliderPreferredSize === MAX - MIN_DISTANCE
? null
: roundNumber(Math.pow(sliderPreferredSize, 1.1)),
maxSize:
sliderMaxSize === MAX
? null
: roundNumber(Math.pow(sliderMaxSize, 1.1)),
});
},
[id, setSizes, onSizeChange]
);
const handleMinSizeChange = useCallback(
({ value }: { value: number }) => {
setSizes({
minSize: value,
preferredSize: sizes.preferredSize,
maxSize: sizes.maxSize,
});
onSizeChange({
id,
minSize: value,
preferredSize: sizes.preferredSize,
maxSize: sizes.maxSize,
});
},
[id, sizes, setSizes, onSizeChange]
);
const handlePreferredSizeChange = useCallback(
({ value }: { value: number }) => {
setSizes({
minSize: sizes.minSize,
preferredSize: value,
maxSize: sizes.maxSize,
});
onSizeChange({
id,
minSize: sizes.minSize,
preferredSize: value,
maxSize: sizes.maxSize,
});
},
[id, sizes, setSizes, onSizeChange]
);
const handleMaxSizeChange = useCallback(
({ value }: { value: number }) => {
setSizes({
minSize: sizes.minSize,
preferredSize: sizes.preferredSize,
maxSize: value,
});
onSizeChange({
id,
minSize: sizes.minSize,
preferredSize: sizes.preferredSize,
maxSize: value,
});
},
[id, sizes, setSizes, onSizeChange]
);
const handleAfterSliderChange = useCallback(() => {
setSizes({
minSize: getSliderValue(minSize, MIN),
maxSize: getSliderValue(maxSize, MAX),
preferredSize: getSliderValue(preferredSize, MAX - MIN_DISTANCE),
});
}, [minSize, maxSize, preferredSize, setSizes]);
const minBytes = (sizes.minSize || 0) * 1024 * 1024;
const minSixty = `${formatBytes(minBytes * 60)}/${translate(
'HourShorthand'
)}`;
const preferredBytes = (sizes.preferredSize || 0) * 1024 * 1024;
const preferredSixty = preferredBytes
? `${formatBytes(preferredBytes * 60)}/${translate('HourShorthand')}`
: translate('Unlimited');
const maxBytes = maxSize && maxSize * 1024 * 1024;
const maxSixty = maxBytes
? `${formatBytes(maxBytes * 60)}/${translate('HourShorthand')}`
: translate('Unlimited');
return (
<div className={styles.sizeLimit}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore React version mismatch */}
<ReactSlider
className={styles.slider}
min={MIN}
max={SLIDER_MAX}
step={STEP_SIZE}
minDistance={3}
value={[sizes.minSize, sizes.preferredSize, sizes.maxSize]}
withTracks={true}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore allowCross is still available in the version currently used
allowCross={false}
snapDragDisabled={true}
renderThumb={thumbRenderer}
renderTrack={trackRenderer}
onChange={handleSliderChange}
onAfterChange={handleAfterSliderChange}
/>
<div className={styles.sizes}>
<div>
<Popover
anchor={<Label kind={kinds.INFO}>{minSixty}</Label>}
title={translate('MinimumLimits')}
body={
<QualityDefinitionLimits
bytes={minBytes}
message={translate('NoMinimumForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={<Label kind={kinds.SUCCESS}>{preferredSixty}</Label>}
title={translate('PreferredSize')}
body={
<QualityDefinitionLimits
bytes={preferredBytes}
message={translate('NoLimitForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={<Label kind={kinds.WARNING}>{maxSixty}</Label>}
title={translate('MaximumLimits')}
body={
<QualityDefinitionLimits
bytes={maxBytes}
message={translate('NoLimitForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
</div>
<div className={styles.megabytesPerMinuteContainer}>
<div className={styles.megabytesPerMinute}>
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={minSize || MIN}
min={MIN}
max={preferredSize ? preferredSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
onChange={handleMinSizeChange}
/>
<Label kind={kinds.INFO}>
{translate('Minimum')} MiB/
{translate('MinuteShorthand')}
</Label>
</div>
<div className={styles.megabytesPerMinute}>
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={preferredSize || MAX - 5}
min={MIN}
max={maxSize ? maxSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
onChange={handlePreferredSizeChange}
/>
<Label kind={kinds.SUCCESS}>
{translate('Preferred')} MiB/
{translate('MinuteShorthand')}
</Label>
</div>
<div className={styles.megabytesPerMinute}>
<NumberInput
className={styles.sizeInput}
name={`${id}.max`}
value={maxSize || MAX}
min={(preferredSize || 0) + STEP_SIZE}
max={MAX}
step={0.1}
isFloat={true}
onChange={handleMaxSizeChange}
/>
<Label kind={kinds.WARNING}>
{translate('Maximum')} MiB/
{translate('MinuteShorthand')}
</Label>
</div>
</div>
</div>
);
}

View File

@ -4,7 +4,14 @@
margin-top: 10px; margin-top: 10px;
} }
.editGroupsButtonIcon { .editSizesButton {
composes: button from '~Components/Link/Button.css';
margin-top: 10px;
margin-left: 10px;
}
.editButtonIcon {
margin-right: 8px; margin-right: 8px;
} }

View File

@ -1,8 +1,9 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'editButtonIcon': string;
'editGroupsButton': string; 'editGroupsButton': string;
'editGroupsButtonIcon': string; 'editSizesButton': string;
'qualities': string; 'qualities': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;

View File

@ -21,8 +21,9 @@ class QualityProfileItems extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
qualitiesHeight: 0, defaultHeight: 0,
qualitiesHeightEditGroups: 0 editGroupsHeight: 0,
editSizesHeight: 0
}; };
} }
@ -30,17 +31,23 @@ class QualityProfileItems extends Component {
// Listeners // Listeners
onMeasure = ({ height }) => { onMeasure = ({ height }) => {
if (this.props.editGroups) { const heightKey = `${this.props.mode}Height`;
this.setState({ this.setState({
qualitiesHeightEditGroups: height [heightKey]: height
}); });
} else {
this.setState({ qualitiesHeight: height });
}
}; };
onToggleEditGroupsMode = () => { onEditGroupsPress = () => {
this.props.onToggleEditGroupsMode(); this.props.onChangeMode('editGroups');
};
onEditSizesPress = () => {
this.props.onChangeMode('editSizes');
};
onDefaultModePress = () => {
this.props.onChangeMode('default');
}; };
// //
@ -48,7 +55,7 @@ class QualityProfileItems extends Component {
render() { render() {
const { const {
editGroups, mode,
dropQualityIndex, dropQualityIndex,
dropPosition, dropPosition,
qualityProfileItems, qualityProfileItems,
@ -57,15 +64,10 @@ class QualityProfileItems extends Component {
...otherProps ...otherProps
} = this.props; } = this.props;
const {
qualitiesHeight,
qualitiesHeightEditGroups
} = this.state;
const isDragging = dropQualityIndex !== null; const isDragging = dropQualityIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above'; const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below'; const isDraggingDown = isDragging && dropPosition === 'below';
const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight; const height = this.state[`${mode}Height`];
return ( return (
<FormGroup size={sizes.EXTRA_SMALL}> <FormGroup size={sizes.EXTRA_SMALL}>
@ -107,16 +109,33 @@ class QualityProfileItems extends Component {
<Button <Button
className={styles.editGroupsButton} className={styles.editGroupsButton}
kind={kinds.PRIMARY} kind={kinds.PRIMARY}
onPress={this.onToggleEditGroupsMode} onPress={mode === 'editGroups' ? this.onDefaultModePress : this.onEditGroupsPress}
> >
<div> <div>
<Icon <Icon
className={styles.editGroupsButtonIcon} className={styles.editButtonIcon}
name={editGroups ? icons.REORDER : icons.GROUP} name={mode === 'editGroups' ? icons.REORDER : icons.GROUP}
/> />
{ {
editGroups ? translate('DoneEditingGroups') : translate('EditGroups') mode === 'editGroups' ? translate('DoneEditingGroups') : translate('EditGroups')
}
</div>
</Button>
<Button
className={styles.editSizesButton}
kind={kinds.PRIMARY}
onPress={mode === 'editSizes' ? this.onDefaultModePress : this.onEditSizesPress}
>
<div>
<Icon
className={styles.editButtonIcon}
name={mode === 'editSizes' ? icons.REORDER : icons.FILE}
/>
{
mode === 'editSizes' ? translate('DoneEditingSizes') : translate('EditSizes')
} }
</div> </div>
</Button> </Button>
@ -128,21 +147,24 @@ class QualityProfileItems extends Component {
> >
<div <div
className={styles.qualities} className={styles.qualities}
style={{ minHeight: `${minHeight}px` }} style={{ height: `${height}px` }}
> >
{ {
qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => { qualityProfileItems.map(({ id, name, allowed, quality, items, minSize, maxSize, preferredSize }, index) => {
const identifier = quality ? quality.id : id; const identifier = quality ? quality.id : id;
return ( return (
<QualityProfileItemDragSource <QualityProfileItemDragSource
key={identifier} key={identifier}
editGroups={editGroups} mode={mode}
groupId={id} groupId={id}
qualityId={quality && quality.id} qualityId={quality && quality.id}
name={quality ? quality.name : name} name={quality ? quality.name : name}
allowed={allowed} allowed={allowed}
items={items} items={items}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
qualityIndex={`${index + 1}`} qualityIndex={`${index + 1}`}
isInGroup={false} isInGroup={false}
isDragging={isDragging} isDragging={isDragging}
@ -164,14 +186,14 @@ class QualityProfileItems extends Component {
} }
QualityProfileItems.propTypes = { QualityProfileItems.propTypes = {
editGroups: PropTypes.bool.isRequired, mode: PropTypes.string.isRequired,
dragQualityIndex: PropTypes.string, dragQualityIndex: PropTypes.string,
dropQualityIndex: PropTypes.string, dropQualityIndex: PropTypes.string,
dropPosition: PropTypes.string, dropPosition: PropTypes.string,
qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object), errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object), warnings: PropTypes.arrayOf(PropTypes.object),
onToggleEditGroupsMode: PropTypes.func.isRequired onChangeMode: PropTypes.func.isRequired
}; };
QualityProfileItems.defaultProps = { QualityProfileItems.defaultProps = {

View File

@ -14,60 +14,6 @@
line-height: 40px; line-height: 40px;
} }
.sizeLimit {
flex: 0 1 500px;
padding-right: 30px;
}
.slider {
width: 100%;
height: 20px;
}
.track {
top: 9px;
margin: 0 5px;
height: 3px;
background-color: var(--sliderAccentColor);
box-shadow: 0 0 0 #000;
&:nth-child(3n+1) {
background-color: #ddd;
}
}
.thumb {
top: 1px;
z-index: 0 !important;
width: 18px;
height: 18px;
border: 3px solid var(--sliderAccentColor);
border-radius: 50%;
background-color: var(--white);
text-align: center;
cursor: pointer;
}
.sizes {
display: flex;
justify-content: space-between;
}
.megabytesPerMinute {
display: flex;
justify-content: space-between;
flex: 0 0 400px;
}
.sizeInput {
composes: input from '~Components/Form/TextInput.css';
display: inline-block;
margin-left: 5px;
padding: 6px;
width: 75px;
}
@media only screen and (max-width: $breakpointSmall) { @media only screen and (max-width: $breakpointSmall) {
.qualityDefinition { .qualityDefinition {
flex-wrap: wrap; flex-wrap: wrap;
@ -86,8 +32,4 @@
font-weight: bold; font-weight: bold;
line-height: inherit; line-height: inherit;
} }
.sizeLimit {
margin-top: 10px;
}
} }

View File

@ -1,16 +1,9 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'megabytesPerMinute': string;
'quality': string; 'quality': string;
'qualityDefinition': string; 'qualityDefinition': string;
'sizeInput': string;
'sizeLimit': string;
'sizes': string;
'slider': string;
'thumb': string;
'title': string; 'title': string;
'track': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default cssExports; export default cssExports;

View File

@ -1,346 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactSlider from 'react-slider';
import NumberInput from 'Components/Form/NumberInput';
import TextInput from 'Components/Form/TextInput';
import Label from 'Components/Label';
import Popover from 'Components/Tooltip/Popover';
import { kinds, tooltipPositions } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import roundNumber from 'Utilities/Number/roundNumber';
import translate from 'Utilities/String/translate';
import QualityDefinitionLimits from './QualityDefinitionLimits';
import styles from './QualityDefinition.css';
const MIN = 0;
const MAX = 400;
const MIN_DISTANCE = 1;
const slider = {
min: MIN,
max: roundNumber(Math.pow(MAX, 1 / 1.1)),
step: 0.1
};
function getValue(inputValue) {
if (inputValue < MIN) {
return MIN;
}
if (inputValue > MAX) {
return MAX;
}
return roundNumber(inputValue);
}
function getSliderValue(value, defaultValue) {
const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue;
return roundNumber(sliderValue);
}
class QualityDefinition extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._forceUpdateTimeout = null;
this.state = {
sliderMinSize: getSliderValue(props.minSize, slider.min),
sliderMaxSize: getSliderValue(props.maxSize, slider.max),
sliderPreferredSize: getSliderValue(props.preferredSize, (slider.max - 3))
};
}
componentDidMount() {
// A hack to deal with a bug in the slider component until a fix for it
// lands and an updated version is available.
// See: https://github.com/mpowaga/react-slider/issues/115
this._forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 1);
}
componentWillUnmount() {
if (this._forceUpdateTimeout) {
clearTimeout(this._forceUpdateTimeout);
}
}
//
// Control
trackRenderer(props, state) {
return (
<div
{...props}
className={styles.track}
/>
);
}
thumbRenderer(props, state) {
return (
<div
{...props}
className={styles.thumb}
/>
);
}
//
// Listeners
onSliderChange = ([sliderMinSize, sliderPreferredSize, sliderMaxSize]) => {
this.setState({
sliderMinSize,
sliderMaxSize,
sliderPreferredSize
});
this.props.onSizeChange({
minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
preferredSize: sliderPreferredSize === (slider.max - 3) ? null : roundNumber(Math.pow(sliderPreferredSize, 1.1)),
maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1))
});
};
onAfterSliderChange = () => {
const {
minSize,
maxSize,
preferredSize
} = this.props;
this.setState({
sliderMiSize: getSliderValue(minSize, slider.min),
sliderMaxSize: getSliderValue(maxSize, slider.max),
sliderPreferredSize: getSliderValue(preferredSize, (slider.max - 3)) // fix
});
};
onMinSizeChange = ({ value }) => {
const minSize = getValue(value);
this.setState({
sliderMinSize: getSliderValue(minSize, slider.min)
});
this.props.onSizeChange({
minSize,
maxSize: this.props.maxSize,
preferredSize: this.props.preferredSize
});
};
onPreferredSizeChange = ({ value }) => {
const preferredSize = value === (MAX - 3) ? null : getValue(value);
this.setState({
sliderPreferredSize: getSliderValue(preferredSize, slider.preferred)
});
this.props.onSizeChange({
minSize: this.props.minSize,
maxSize: this.props.maxSize,
preferredSize
});
};
onMaxSizeChange = ({ value }) => {
const maxSize = value === MAX ? null : getValue(value);
this.setState({
sliderMaxSize: getSliderValue(maxSize, slider.max)
});
this.props.onSizeChange({
minSize: this.props.minSize,
maxSize,
preferredSize: this.props.preferredSize
});
};
//
// Render
render() {
const {
id,
quality,
title,
minSize,
maxSize,
preferredSize,
advancedSettings,
onTitleChange
} = this.props;
const {
sliderMinSize,
sliderMaxSize,
sliderPreferredSize
} = this.state;
const minBytes = minSize * 1024 * 1024;
const minSixty = `${formatBytes(minBytes * 60)}/${translate('HourShorthand')}`;
const preferredBytes = preferredSize * 1024 * 1024;
const preferredSixty = preferredBytes ? `${formatBytes(preferredBytes * 60)}/${translate('HourShorthand')}` : translate('Unlimited');
const maxBytes = maxSize && maxSize * 1024 * 1024;
const maxSixty = maxBytes ? `${formatBytes(maxBytes * 60)}/${translate('HourShorthand')}` : translate('Unlimited');
return (
<div className={styles.qualityDefinition}>
<div className={styles.quality}>
{quality.name}
</div>
<div className={styles.title}>
<TextInput
name={`${id}.${title}`}
value={title}
onChange={onTitleChange}
/>
</div>
<div className={styles.sizeLimit}>
<ReactSlider
className={styles.slider}
min={slider.min}
max={slider.max}
step={slider.step}
minDistance={3}
value={[sliderMinSize, sliderPreferredSize, sliderMaxSize]}
withTracks={true}
allowCross={false}
snapDragDisabled={true}
renderThumb={this.thumbRenderer}
renderTrack={this.trackRenderer}
onChange={this.onSliderChange}
onAfterChange={this.onAfterSliderChange}
/>
<div className={styles.sizes}>
<div>
<Popover
anchor={
<Label kind={kinds.INFO}>{minSixty}</Label>
}
title={translate('MinimumLimits')}
body={
<QualityDefinitionLimits
bytes={minBytes}
message={translate('NoMinimumForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={
<Label kind={kinds.SUCCESS}>{preferredSixty}</Label>
}
title={translate('PreferredSize')}
body={
<QualityDefinitionLimits
bytes={preferredBytes}
message={translate('NoLimitForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={
<Label kind={kinds.WARNING}>{maxSixty}</Label>
}
title={translate('MaximumLimits')}
body={
<QualityDefinitionLimits
bytes={maxBytes}
message={translate('NoLimitForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
</div>
</div>
{
advancedSettings &&
<div className={styles.megabytesPerMinute}>
<div>
{translate('Min')}
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={minSize || MIN}
min={MIN}
max={preferredSize ? preferredSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
onChange={this.onMinSizeChange}
/>
</div>
<div>
{translate('Preferred')}
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={preferredSize || MAX - 5}
min={MIN}
max={maxSize ? maxSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
onChange={this.onPreferredSizeChange}
/>
</div>
<div>
{translate('Max')}
<NumberInput
className={styles.sizeInput}
name={`${id}.max`}
value={maxSize || MAX}
min={minSize + MIN_DISTANCE}
max={MAX}
step={0.1}
isFloat={true}
onChange={this.onMaxSizeChange}
/>
</div>
</div>
}
</div>
);
}
}
QualityDefinition.propTypes = {
id: PropTypes.number.isRequired,
quality: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
advancedSettings: PropTypes.bool.isRequired,
onTitleChange: PropTypes.func.isRequired,
onSizeChange: PropTypes.func.isRequired
};
export default QualityDefinition;

View File

@ -0,0 +1,46 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import TextInput from 'Components/Form/TextInput';
import Quality from 'Quality/Quality';
import { setQualityDefinitionValue } from 'Store/Actions/settingsActions';
import styles from './QualityDefinition.css';
interface QualityDefinitionProps {
id: number;
quality: Quality;
title: string;
}
function QualityDefinition(props: QualityDefinitionProps) {
const { id, quality, title } = props;
const dispatch = useDispatch();
const handleTitleChange = useCallback(
({ value }: { value: string }) => {
dispatch(
setQualityDefinitionValue({
id,
name: 'title',
value,
})
);
},
[id, dispatch]
);
return (
<div className={styles.qualityDefinition}>
<div className={styles.quality}>{quality.name}</div>
<div className={styles.title}>
<TextInput
name={`${id}.${title}`}
value={title}
onChange={handleTitleChange}
/>
</div>
</div>
);
}
export default QualityDefinition;

View File

@ -1,70 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { setQualityDefinitionValue } from 'Store/Actions/settingsActions';
import QualityDefinition from './QualityDefinition';
const mapDispatchToProps = {
setQualityDefinitionValue,
clearPendingChanges
};
class QualityDefinitionConnector extends Component {
componentWillUnmount() {
this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' });
}
//
// Listeners
onTitleChange = ({ value }) => {
this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value });
};
onSizeChange = ({ minSize, maxSize, preferredSize }) => {
const {
id,
minSize: currentMinSize,
maxSize: currentMaxSize,
preferredSize: currentPreferredSize
} = this.props;
if (minSize !== currentMinSize) {
this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize });
}
if (maxSize !== currentMaxSize) {
this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize });
}
if (preferredSize !== currentPreferredSize) {
this.props.setQualityDefinitionValue({ id, name: 'preferredSize', value: preferredSize });
}
};
//
// Render
render() {
return (
<QualityDefinition
{...this.props}
onTitleChange={this.onTitleChange}
onSizeChange={this.onSizeChange}
/>
);
}
}
QualityDefinitionConnector.propTypes = {
id: PropTypes.number.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
setQualityDefinitionValue: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(null, mapDispatchToProps)(QualityDefinitionConnector);

View File

@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
function QualityDefinitionLimits(props) {
const {
bytes,
message
} = props;
if (!bytes) {
return <div>{message}</div>;
}
const thirty = formatBytes(bytes * 30);
const fortyFive = formatBytes(bytes * 45);
const sixty = formatBytes(bytes * 60);
return (
<div>
<div>
{translate('MinutesThirty', { thirty })}
</div>
<div>
{translate('MinutesFortyFive', { fortyFive })}
</div>
<div>
{translate('MinutesSixty', { sixty })}
</div>
</div>
);
}
QualityDefinitionLimits.propTypes = {
bytes: PropTypes.number,
message: PropTypes.string.isRequired
};
export default QualityDefinitionLimits;

View File

@ -0,0 +1,30 @@
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
interface QualityDefinitionLimitsProps {
bytes: number | null;
message: string;
}
function QualityDefinitionLimits(props: QualityDefinitionLimitsProps) {
const { bytes, message } = props;
if (!bytes) {
return <div>{message}</div>;
}
const thirty = formatBytes(bytes * 30);
const fortyFive = formatBytes(bytes * 45);
const sixty = formatBytes(bytes * 60);
return (
<div>
<div>{translate('MinutesThirty', { thirty })}</div>
<div>{translate('MinutesFortyFive', { fortyFive })}</div>
<div>{translate('MinutesSixty', { sixty })}</div>
</div>
);
}
export default QualityDefinitionLimits;

View File

@ -8,24 +8,11 @@
flex: 0 1 250px; flex: 0 1 250px;
} }
.sizeLimit { .notice {
flex: 0 1 500px; composes: alert from '~Components/Alert.css';
}
.megabytesPerMinute { margin: 0;
flex: 0 0 250px; margin-bottom: 20px;
}
.sizeLimitHelpTextContainer {
display: flex;
justify-content: flex-end;
margin-top: 20px;
max-width: 1000px;
}
.sizeLimitHelpText {
max-width: 500px;
color: var(--helpTextColor);
} }
@media only screen and (max-width: $breakpointSmall) { @media only screen and (max-width: $breakpointSmall) {

View File

@ -3,11 +3,8 @@
interface CssExports { interface CssExports {
'definitions': string; 'definitions': string;
'header': string; 'header': string;
'megabytesPerMinute': string; 'notice': string;
'quality': string; 'quality': string;
'sizeLimit': string;
'sizeLimitHelpText': string;
'sizeLimitHelpTextContainer': string;
'title': string; 'title': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;

View File

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import translate from 'Utilities/String/translate';
import QualityDefinitionConnector from './QualityDefinitionConnector';
import styles from './QualityDefinitions.css';
class QualityDefinitions extends Component {
//
// Render
render() {
const {
items,
advancedSettings,
...otherProps
} = this.props;
return (
<FieldSet legend={translate('QualityDefinitions')}>
<PageSectionContent
errorMessage={translate('QualityDefinitionsLoadError')}
{...otherProps}
>
<div className={styles.header}>
<div className={styles.quality}>
{translate('Quality')}
</div>
<div className={styles.title}>
{translate('Title')}
</div>
<div className={styles.sizeLimit}>
{translate('SizeLimit')}
</div>
{
advancedSettings ?
<div className={styles.megabytesPerMinute}>
{translate('MegabytesPerMinute')}
</div> :
null
}
</div>
<div className={styles.definitions}>
{
items.map((item) => {
return (
<QualityDefinitionConnector
key={item.id}
{...item}
advancedSettings={advancedSettings}
/>
);
})
}
</div>
<div className={styles.sizeLimitHelpTextContainer}>
<div className={styles.sizeLimitHelpText}>
{translate('QualityLimitsSeriesRuntimeHelpText')}
</div>
</div>
</PageSectionContent>
</FieldSet>
);
}
}
QualityDefinitions.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
defaultProfile: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
advancedSettings: PropTypes.bool.isRequired
};
export default QualityDefinitions;

View File

@ -0,0 +1,106 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchQualityDefinitions,
saveQualityDefinitions,
} from 'Store/Actions/settingsActions';
import translate from 'Utilities/String/translate';
import QualityDefinition from './QualityDefinition';
import styles from './QualityDefinitions.css';
function qualityDefinitionsSelector() {
return createSelector(
(state: AppState) => state.settings.qualityDefinitions,
(qualityDefinitions) => {
const items = qualityDefinitions.items.map((item) => {
const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {};
return Object.assign({}, item, pendingChanges);
});
return {
...qualityDefinitions,
items,
hasPendingChanges: !!Object.keys(qualityDefinitions.pendingChanges)
.length,
};
}
);
}
interface QualityDefinitionsProps {
isSaving: boolean;
onChildMounted: (saveCallback: () => void) => void;
onChildStateChange: ({
isSaving,
hasPendingChanges,
}: {
isSaving: boolean;
hasPendingChanges: boolean;
}) => void;
}
function QualityDefinitions(props: QualityDefinitionsProps) {
const { isSaving, onChildMounted, onChildStateChange } = props;
const { isFetching, isPopulated, error, items, hasPendingChanges } =
useSelector(qualityDefinitionsSelector());
const dispatch = useDispatch();
const handleSavePress = useCallback(() => {
dispatch(saveQualityDefinitions());
}, [dispatch]);
useEffect(() => {
dispatch(fetchQualityDefinitions());
return () => {
dispatch(
clearPendingChanges({
section: 'settings.qualityDefinitions',
})
);
};
}, [dispatch]);
useEffect(() => {
onChildMounted(handleSavePress);
}, [handleSavePress, onChildMounted]);
useEffect(() => {
onChildStateChange({ isSaving, hasPendingChanges });
}, [isSaving, hasPendingChanges, onChildStateChange]);
return (
<FieldSet legend={translate('QualityDefinitions')}>
<Alert className={styles.notice}>
{translate('QualityDefinitionsSizeNotice')}
</Alert>
<PageSectionContent
error={error}
errorMessage={translate('QualityDefinitionsLoadError')}
isFetching={isFetching}
isPopulated={isPopulated}
>
<div className={styles.header}>
<div className={styles.quality}>{translate('Quality')}</div>
<div className={styles.title}>{translate('Title')}</div>
</div>
<div className={styles.definitions}>
{items.map((item) => {
return <QualityDefinition key={item.id} {...item} />;
})}
</div>
</PageSectionContent>
</FieldSet>
);
}
export default QualityDefinitions;

View File

@ -1,90 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchQualityDefinitions, saveQualityDefinitions } from 'Store/Actions/settingsActions';
import QualityDefinitions from './QualityDefinitions';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityDefinitions,
(state) => state.settings.advancedSettings,
(qualityDefinitions, advancedSettings) => {
const items = qualityDefinitions.items.map((item) => {
const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {};
return Object.assign({}, item, pendingChanges);
});
return {
...qualityDefinitions,
items,
hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges),
advancedSettings
};
}
);
}
const mapDispatchToProps = {
dispatchFetchQualityDefinitions: fetchQualityDefinitions,
dispatchSaveQualityDefinitions: saveQualityDefinitions
};
class QualityDefinitionsConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
dispatchFetchQualityDefinitions,
dispatchSaveQualityDefinitions,
onChildMounted
} = this.props;
dispatchFetchQualityDefinitions();
onChildMounted(dispatchSaveQualityDefinitions);
}
componentDidUpdate(prevProps) {
const {
hasPendingChanges,
isSaving,
onChildStateChange
} = this.props;
if (
prevProps.isSaving !== isSaving ||
prevProps.hasPendingChanges !== hasPendingChanges
) {
onChildStateChange({
isSaving,
hasPendingChanges
});
}
}
//
// Render
render() {
return (
<QualityDefinitions
{...this.props}
/>
);
}
}
QualityDefinitionsConnector.propTypes = {
isSaving: PropTypes.bool.isRequired,
hasPendingChanges: PropTypes.bool.isRequired,
dispatchFetchQualityDefinitions: PropTypes.func.isRequired,
dispatchSaveQualityDefinitions: PropTypes.func.isRequired,
onChildMounted: PropTypes.func.isRequired,
onChildStateChange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps, null)(QualityDefinitionsConnector);

View File

@ -7,7 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector'; import QualityDefinitions from './Definition/QualityDefinitions';
import ResetQualityDefinitionsModal from './Reset/ResetQualityDefinitionsModal'; import ResetQualityDefinitionsModal from './Reset/ResetQualityDefinitionsModal';
class Quality extends Component { class Quality extends Component {
@ -83,7 +83,7 @@ class Quality extends Component {
/> />
<PageContentBody> <PageContentBody>
<QualityDefinitionsConnector <QualityDefinitions
onChildMounted={this.onChildMounted} onChildMounted={this.onChildMounted}
onChildStateChange={this.onChildStateChange} onChildStateChange={this.onChildStateChange}
/> />

View File

@ -0,0 +1,10 @@
import Quality from 'Quality/Quality';
interface QualityDefinition {
id: number;
quality: Quality;
title: string;
weight: number;
}
export default QualityDefinition;

View File

@ -94,6 +94,7 @@
"@types/lodash": "4.14.194", "@types/lodash": "4.14.194",
"@types/react-lazyload": "3.2.0", "@types/react-lazyload": "3.2.0",
"@types/react-router-dom": "5.3.3", "@types/react-router-dom": "5.3.3",
"@types/react-slider": "1.3.6",
"@types/react-text-truncate": "0.14.1", "@types/react-text-truncate": "0.14.1",
"@types/react-window": "1.8.5", "@types/react-window": "1.8.5",
"@types/redux-actions": "2.6.2", "@types/redux-actions": "2.6.2",

View File

@ -4,8 +4,10 @@ using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
@ -21,13 +23,49 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
private RemoteEpisode _parseResultSingle; private RemoteEpisode _parseResultSingle;
private Series _series; private Series _series;
private List<Episode> _episodes; private List<Episode> _episodes;
private QualityDefinition _qualityType;
[SetUp] [SetUp]
public void Setup() public void Setup()
{ {
_series = Builder<Series>.CreateNew() _series = Builder<Series>.CreateNew()
.With(s => s.Seasons = Builder<Season>.CreateListOfSize(2).Build().ToList()) .With(s => s.Seasons = Builder<Season>.CreateListOfSize(2).Build().ToList())
.With(c => c.QualityProfile = (LazyLoaded<QualityProfile>)new QualityProfile
{
Cutoff = Quality.SDTV.Id, Items = new List<QualityProfileQualityItem>
{
new QualityProfileQualityItem
{
Quality = Quality.SDTV,
MinSize = 2,
MaxSize = 10
},
new QualityProfileQualityItem
{
Quality = Quality.RAWHD,
MinSize = 2,
MaxSize = null
},
new QualityProfileQualityItem
{
Name = "WEB 720p",
Items = new List<QualityProfileQualityItem>
{
new QualityProfileQualityItem
{
Quality = Quality.WEBDL720p,
MinSize = 2,
MaxSize = 20,
},
new QualityProfileQualityItem
{
Quality = Quality.WEBRip720p,
MinSize = 2,
MaxSize = 20,
}
}
}
}
})
.Build(); .Build();
_episodes = Builder<Episode>.CreateListOfSize(10) _episodes = Builder<Episode>.CreateListOfSize(10)
@ -79,19 +117,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.Setup(v => v.Get(It.IsAny<Quality>())) .Setup(v => v.Get(It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
_qualityType = Builder<QualityDefinition>.CreateNew()
.With(q => q.MinSize = 2)
.With(q => q.MaxSize = 10)
.With(q => q.Quality = Quality.SDTV)
.Build();
Mocker.GetMock<IQualityDefinitionService>().Setup(s => s.Get(Quality.SDTV)).Returns(_qualityType);
Mocker.GetMock<IEpisodeService>().Setup( Mocker.GetMock<IEpisodeService>().Setup(
s => s.GetEpisodesBySeason(It.IsAny<int>(), It.IsAny<int>())) s => s.GetEpisodesBySeason(It.IsAny<int>(), It.IsAny<int>()))
.Returns(_episodes); .Returns(_episodes);
} }
private void WithSize(int? minSize, int? maxSize)
{
_series.QualityProfile.Value.Items[0].MinSize = minSize;
_series.QualityProfile.Value.Items[0].MaxSize = maxSize;
}
[TestCase(30, 50, false)] [TestCase(30, 50, false)]
[TestCase(30, 250, true)] [TestCase(30, 250, true)]
[TestCase(30, 500, false)] [TestCase(30, 500, false)]
@ -174,12 +210,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_return_true_if_size_is_zero() public void should_return_true_if_size_is_zero()
{ {
WithSize(10, 20);
_series.Runtime = 30; _series.Runtime = 30;
_parseResultSingle.Series = _series; _parseResultSingle.Series = _series;
_parseResultSingle.Release.Size = 0; _parseResultSingle.Release.Size = 0;
_parseResultSingle.Episodes.First().Runtime = 30; _parseResultSingle.Episodes.First().Runtime = 30;
_qualityType.MinSize = 10;
_qualityType.MaxSize = 20;
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue();
} }
@ -187,11 +223,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_return_true_if_unlimited_30_minute() public void should_return_true_if_unlimited_30_minute()
{ {
WithSize(2, null);
_series.Runtime = 30; _series.Runtime = 30;
_parseResultSingle.Series = _series; _parseResultSingle.Series = _series;
_parseResultSingle.Release.Size = 18457280000; _parseResultSingle.Release.Size = 18457280000;
_parseResultSingle.Episodes.First().Runtime = 30; _parseResultSingle.Episodes.First().Runtime = 30;
_qualityType.MaxSize = null;
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue();
} }
@ -199,11 +236,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_return_true_if_unlimited_60_minute() public void should_return_true_if_unlimited_60_minute()
{ {
WithSize(2, null);
_series.Runtime = 60; _series.Runtime = 60;
_parseResultSingle.Series = _series; _parseResultSingle.Series = _series;
_parseResultSingle.Release.Size = 36857280000; _parseResultSingle.Release.Size = 36857280000;
_parseResultSingle.Episodes.First().Runtime = 60; _parseResultSingle.Episodes.First().Runtime = 60;
_qualityType.MaxSize = null;
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue();
} }
@ -217,7 +255,19 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
_parseResultSingle.Release.Size = 300.Megabytes(); _parseResultSingle.Release.Size = 300.Megabytes();
_parseResultSingle.Episodes.First().Runtime = 60; _parseResultSingle.Episodes.First().Runtime = 60;
_qualityType.MaxSize = 10; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue();
}
[Test]
public void should_use_grouped_quality_limits()
{
_parseResultSingle.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL720p);
_series.Runtime = 30;
_parseResultSingle.Series = _series;
_parseResultSingle.Series.SeriesType = SeriesTypes.Daily;
_parseResultSingle.Release.Size = 500.Megabytes();
_parseResultSingle.Episodes.First().Runtime = 30;
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue();
} }

View File

@ -22,21 +22,35 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[TestFixture] [TestFixture]
public class PrioritizeDownloadDecisionFixture : CoreTest<DownloadDecisionPriorizationService> public class PrioritizeDownloadDecisionFixture : CoreTest<DownloadDecisionPriorizationService>
{ {
private Series _series;
[SetUp] [SetUp]
public void Setup() public void Setup()
{ {
GivenPreferredDownloadProtocol(DownloadProtocol.Usenet); GivenPreferredDownloadProtocol(DownloadProtocol.Usenet);
Mocker.GetMock<IQualityDefinitionService>() _series = Builder<Series>.CreateNew()
.Setup(s => s.Get(It.IsAny<Quality>())) .With(e => e.Runtime = 60)
.Returns(new QualityDefinition { PreferredSize = null }); .With(e => e.QualityProfile = new QualityProfile
{
Items = Qualities.QualityFixture.GetDefaultQualities()
})
.Build();
} }
private void GivenPreferredSize(double? size) private void GivenPreferredSize(QualityProfile qualityProfile, double? size)
{ {
Mocker.GetMock<IQualityDefinitionService>() foreach (var qualityOrGroup in qualityProfile.Items)
.Setup(s => s.Get(It.IsAny<Quality>())) {
.Returns(new QualityDefinition { PreferredSize = size }); if (qualityOrGroup.Quality != null)
{
qualityOrGroup.PreferredSize = size;
}
else
{
qualityOrGroup.Items.ForEach(i => i.PreferredSize = size);
}
}
} }
private Episode GivenEpisode(int id) private Episode GivenEpisode(int id)
@ -63,13 +77,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
remoteEpisode.Release.DownloadProtocol = downloadProtocol; remoteEpisode.Release.DownloadProtocol = downloadProtocol;
remoteEpisode.Release.IndexerPriority = indexerPriority; remoteEpisode.Release.IndexerPriority = indexerPriority;
remoteEpisode.Series = Builder<Series>.CreateNew() remoteEpisode.Series = _series;
.With(e => e.Runtime = 60)
.With(e => e.QualityProfile = new QualityProfile
{
Items = Qualities.QualityFixture.GetDefaultQualities()
})
.Build();
return remoteEpisode; return remoteEpisode;
} }
@ -176,7 +184,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void should_order_by_closest_to_preferred_size_if_both_under() public void should_order_by_closest_to_preferred_size_if_both_under()
{ {
// 200 MB/Min * 60 Min Runtime = 12000 MB // 200 MB/Min * 60 Min Runtime = 12000 MB
GivenPreferredSize(200); GivenPreferredSize(_series.QualityProfile.Value, 200);
var remoteEpisodeSmall = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 1200.Megabytes(), age: 1); var remoteEpisodeSmall = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 1200.Megabytes(), age: 1);
var remoteEpisodeLarge = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 10000.Megabytes(), age: 1); var remoteEpisodeLarge = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 10000.Megabytes(), age: 1);
@ -193,7 +201,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
public void should_order_by_closest_to_preferred_size_if_preferred_is_in_between() public void should_order_by_closest_to_preferred_size_if_preferred_is_in_between()
{ {
// 46 MB/Min * 60 Min Runtime = 6900 MB // 46 MB/Min * 60 Min Runtime = 6900 MB
GivenPreferredSize(46); GivenPreferredSize(_series.QualityProfile.Value, 46);
var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 500.Megabytes(), age: 1); var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 500.Megabytes(), age: 1);
var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 2000.Megabytes(), age: 1); var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), Language.English, size: 2000.Megabytes(), age: 1);

View File

@ -0,0 +1,182 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using Dapper;
using FluentMigrator;
using Newtonsoft.Json;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(207)]
public class add_size_to_quality_profiles : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Execute.WithConnection(ConvertProfile);
Delete.Column("MinSize").FromTable("QualityDefinitions");
Delete.Column("MaxSize").FromTable("QualityDefinitions");
Delete.Column("PreferredSize").FromTable("QualityDefinitions");
}
private void ConvertProfile(IDbConnection conn, IDbTransaction tran)
{
var updater = new ProfileUpdater207(conn, tran);
updater.SetSizes();
updater.Commit();
}
}
public class Definition207
{
public double? MinSize { get; set; }
public double? MaxSize { get; set; }
public double? PreferredSize { get; set; }
}
public class Profile207
{
public int Id { get; set; }
public List<ProfileItem207> Items { get; set; }
}
public class ProfileItem207
{
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public int Id { get; set; }
public string Name { get; set; }
public int? Quality { get; set; }
public List<ProfileItem207> Items { get; set; }
public bool Allowed { get; set; }
public double? MinSize { get; set; }
public double? MaxSize { get; set; }
public double? PreferredSize { get; set; }
}
public class ProfileUpdater207
{
private readonly IDbConnection _connection;
private readonly IDbTransaction _transaction;
private List<Profile207> _profiles;
private Dictionary<int, Definition207> _sizes;
private HashSet<Profile207> _changedProfiles = new HashSet<Profile207>();
public ProfileUpdater207(IDbConnection conn, IDbTransaction tran)
{
_connection = conn;
_transaction = tran;
_profiles = GetProfiles();
_sizes = GetSizes();
}
public void Commit()
{
var profilesToUpdate = _changedProfiles.Select(p => new
{
Id = p.Id,
Items = p.Items.ToJson()
});
var updateSql = $"UPDATE \"QualityProfiles\" SET \"Items\" = @Items WHERE \"Id\" = @Id";
_connection.Execute(updateSql, profilesToUpdate, transaction: _transaction);
_changedProfiles.Clear();
}
public void SetSizes()
{
foreach (var profile in _profiles)
{
foreach (var item in profile.Items)
{
if (item.Quality.HasValue)
{
if (_sizes.TryGetValue(item.Quality.Value, out var sizes))
{
item.MinSize = sizes.MinSize;
item.MaxSize = sizes.MaxSize;
item.PreferredSize = sizes.PreferredSize;
}
}
foreach (var groupedItem in item.Items)
{
if (groupedItem.Quality.HasValue)
{
if (_sizes.TryGetValue(groupedItem.Quality.Value, out var sizes))
{
groupedItem.MinSize = sizes.MinSize;
groupedItem.MaxSize = sizes.MaxSize;
groupedItem.PreferredSize = sizes.PreferredSize;
}
}
}
}
_changedProfiles.Add(profile);
}
}
private List<Profile207> GetProfiles()
{
var profiles = new List<Profile207>();
using (var getProfilesCmd = _connection.CreateCommand())
{
getProfilesCmd.Transaction = _transaction;
getProfilesCmd.CommandText = "SELECT \"Id\", \"Items\" FROM \"QualityProfiles\"";
using (var profileReader = getProfilesCmd.ExecuteReader())
{
while (profileReader.Read())
{
profiles.Add(new Profile207
{
Id = profileReader.GetInt32(0),
Items = Json.Deserialize<List<ProfileItem207>>(profileReader.GetString(1))
});
}
}
}
return profiles;
}
private Dictionary<int, Definition207> GetSizes()
{
var sizes = new Dictionary<int, Definition207>();
using (var getDefinitionsCmd = _connection.CreateCommand())
{
getDefinitionsCmd.Transaction = _transaction;
getDefinitionsCmd.CommandText = "SELECT \"Id\", \"MinSize\", \"MaxSize\", \"PreferredSize\" FROM \"QualityDefinitions\"";
using (var reader = getDefinitionsCmd.ExecuteReader())
{
while (reader.Read())
{
var id = reader.GetInt32(0);
double.TryParse(reader.GetValue(1).ToString(), out var minSize);
double.TryParse(reader.GetValue(2).ToString(), out var maxSize);
double.TryParse(reader.GetValue(3).ToString(), out var preferredSize);
sizes.Add(id, new Definition207
{
MinSize = minSize,
MaxSize = maxSize,
PreferredSize = preferredSize
});
}
}
}
return sizes;
}
}
}

View File

@ -132,7 +132,10 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity<QualityDefinition>("QualityDefinitions").RegisterModel() Mapper.Entity<QualityDefinition>("QualityDefinitions").RegisterModel()
.Ignore(d => d.GroupName) .Ignore(d => d.GroupName)
.Ignore(d => d.Weight); .Ignore(d => d.Weight)
.Ignore(d => d.MinSize)
.Ignore(d => d.MaxSize)
.Ignore(d => d.PreferredSize);
Mapper.Entity<CustomFormat>("CustomFormats").RegisterModel(); Mapper.Entity<CustomFormat>("CustomFormats").RegisterModel();

View File

@ -14,16 +14,14 @@ namespace NzbDrone.Core.DecisionEngine
{ {
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly IDelayProfileService _delayProfileService; private readonly IDelayProfileService _delayProfileService;
private readonly IQualityDefinitionService _qualityDefinitionService;
public delegate int CompareDelegate(DownloadDecision x, DownloadDecision y); public delegate int CompareDelegate(DownloadDecision x, DownloadDecision y);
public delegate int CompareDelegate<TSubject, TValue>(DownloadDecision x, DownloadDecision y); public delegate int CompareDelegate<TSubject, TValue>(DownloadDecision x, DownloadDecision y);
public DownloadDecisionComparer(IConfigService configService, IDelayProfileService delayProfileService, IQualityDefinitionService qualityDefinitionService) public DownloadDecisionComparer(IConfigService configService, IDelayProfileService delayProfileService)
{ {
_configService = configService; _configService = configService;
_delayProfileService = delayProfileService; _delayProfileService = delayProfileService;
_qualityDefinitionService = qualityDefinitionService;
} }
public int Compare(DownloadDecision x, DownloadDecision y) public int Compare(DownloadDecision x, DownloadDecision y)
@ -184,15 +182,19 @@ namespace NzbDrone.Core.DecisionEngine
{ {
var sizeCompare = CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => var sizeCompare = CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode =>
{ {
var preferredSize = _qualityDefinitionService.Get(remoteEpisode.ParsedEpisodeInfo.Quality.Quality).PreferredSize; var qualityProfile = remoteEpisode.Series.QualityProfile.Value;
var qualityIndex = qualityProfile.GetIndex(remoteEpisode.ParsedEpisodeInfo.Quality.Quality, true);
var qualityOrGroup = qualityProfile.Items[qualityIndex.Index];
var item = qualityOrGroup.Quality == null ? qualityOrGroup.Items[qualityIndex.GroupIndex] : qualityOrGroup;
var preferredSize = item.PreferredSize;
// If no value for preferred it means unlimited so fallback to sort largest is best // If no value for preferred it means unlimited so fallback to sort largest is best
if (preferredSize.HasValue && remoteEpisode.Series.Runtime > 0) if (preferredSize.HasValue && remoteEpisode.Series.Runtime > 0)
{ {
var preferredMovieSize = remoteEpisode.Series.Runtime * preferredSize.Value.Megabytes(); var preferredEpisodeSize = remoteEpisode.Series.Runtime * preferredSize.Value.Megabytes();
// Calculate closest to the preferred size // Calculate closest to the preferred size
return Math.Abs((remoteEpisode.Release.Size - preferredMovieSize).Round(200.Megabytes())) * (-1); return Math.Abs((remoteEpisode.Release.Size - preferredEpisodeSize).Round(200.Megabytes())) * (-1);
} }
else else
{ {

View File

@ -2,7 +2,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Profiles.Delay;
using NzbDrone.Core.Qualities;
namespace NzbDrone.Core.DecisionEngine namespace NzbDrone.Core.DecisionEngine
{ {
@ -15,13 +14,11 @@ namespace NzbDrone.Core.DecisionEngine
{ {
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly IDelayProfileService _delayProfileService; private readonly IDelayProfileService _delayProfileService;
private readonly IQualityDefinitionService _qualityDefinitionService;
public DownloadDecisionPriorizationService(IConfigService configService, IDelayProfileService delayProfileService, IQualityDefinitionService qualityDefinitionService) public DownloadDecisionPriorizationService(IConfigService configService, IDelayProfileService delayProfileService)
{ {
_configService = configService; _configService = configService;
_delayProfileService = delayProfileService; _delayProfileService = delayProfileService;
_qualityDefinitionService = qualityDefinitionService;
} }
public List<DownloadDecision> PrioritizeDecisions(List<DownloadDecision> decisions) public List<DownloadDecision> PrioritizeDecisions(List<DownloadDecision> decisions)
@ -29,7 +26,7 @@ namespace NzbDrone.Core.DecisionEngine
return decisions.Where(c => c.RemoteEpisode.Series != null) return decisions.Where(c => c.RemoteEpisode.Series != null)
.GroupBy(c => c.RemoteEpisode.Series.Id, (seriesId, downloadDecisions) => .GroupBy(c => c.RemoteEpisode.Series.Id, (seriesId, downloadDecisions) =>
{ {
return downloadDecisions.OrderByDescending(decision => decision, new DownloadDecisionComparer(_configService, _delayProfileService, _qualityDefinitionService)); return downloadDecisions.OrderByDescending(decision => decision, new DownloadDecisionComparer(_configService, _delayProfileService));
}) })
.SelectMany(c => c) .SelectMany(c => c)
.Union(decisions.Where(c => c.RemoteEpisode.Series == null)) .Union(decisions.Where(c => c.RemoteEpisode.Series == null))

View File

@ -3,20 +3,17 @@ using NLog;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
namespace NzbDrone.Core.DecisionEngine.Specifications namespace NzbDrone.Core.DecisionEngine.Specifications
{ {
public class AcceptableSizeSpecification : IDecisionEngineSpecification public class AcceptableSizeSpecification : IDecisionEngineSpecification
{ {
private readonly IQualityDefinitionService _qualityDefinitionService;
private readonly IEpisodeService _episodeService; private readonly IEpisodeService _episodeService;
private readonly Logger _logger; private readonly Logger _logger;
public AcceptableSizeSpecification(IQualityDefinitionService qualityDefinitionService, IEpisodeService episodeService, Logger logger) public AcceptableSizeSpecification(IEpisodeService episodeService, Logger logger)
{ {
_qualityDefinitionService = qualityDefinitionService;
_episodeService = episodeService; _episodeService = episodeService;
_logger = logger; _logger = logger;
} }
@ -78,11 +75,14 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
return Decision.Reject("Runtime of all episodes is 0, unable to validate size until it is available"); return Decision.Reject("Runtime of all episodes is 0, unable to validate size until it is available");
} }
var qualityDefinition = _qualityDefinitionService.Get(quality); var qualityProfile = subject.Series.QualityProfile.Value;
var qualityIndex = qualityProfile.GetIndex(quality, true);
var qualityOrGroup = qualityProfile.Items[qualityIndex.Index];
var item = qualityOrGroup.Quality == null ? qualityOrGroup.Items[qualityIndex.GroupIndex] : qualityOrGroup;
if (qualityDefinition.MinSize.HasValue) if (item.MinSize.HasValue)
{ {
var minSize = qualityDefinition.MinSize.Value.Megabytes(); var minSize = item.MinSize.Value.Megabytes();
// Multiply maxSize by runtime of all episodes // Multiply maxSize by runtime of all episodes
minSize *= runtime; minSize *= runtime;
@ -97,13 +97,13 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
} }
} }
if (!qualityDefinition.MaxSize.HasValue || qualityDefinition.MaxSize.Value == 0) if (!item.MaxSize.HasValue || item.MaxSize.Value == 0)
{ {
_logger.Debug("Max size is unlimited, skipping size check"); _logger.Debug("Max size is unlimited, skipping size check");
} }
else else
{ {
var maxSize = qualityDefinition.MaxSize.Value.Megabytes(); var maxSize = item.MaxSize.Value.Megabytes();
// Multiply maxSize by runtime of all episodes // Multiply maxSize by runtime of all episodes
maxSize *= runtime; maxSize *= runtime;

View File

@ -406,6 +406,7 @@
"Donate": "Donate", "Donate": "Donate",
"Donations": "Donations", "Donations": "Donations",
"DoneEditingGroups": "Done Editing Groups", "DoneEditingGroups": "Done Editing Groups",
"DoneEditingSizes": "Done Editing Sizes",
"DotNetVersion": ".NET", "DotNetVersion": ".NET",
"Download": "Download", "Download": "Download",
"DownloadClient": "Download Client", "DownloadClient": "Download Client",
@ -590,6 +591,7 @@
"EditSelectedSeries": "Edit Selected Series", "EditSelectedSeries": "Edit Selected Series",
"EditSeries": "Edit Series", "EditSeries": "Edit Series",
"EditSeriesModalHeader": "Edit - {title}", "EditSeriesModalHeader": "Edit - {title}",
"EditSizes": "Edit Sizes",
"Enable": "Enable", "Enable": "Enable",
"EnableAutomaticAdd": "Enable Automatic Add", "EnableAutomaticAdd": "Enable Automatic Add",
"EnableAutomaticAddSeriesHelpText": "Add series from this list to {appName} when syncs are performed via the UI or by {appName}", "EnableAutomaticAddSeriesHelpText": "Add series from this list to {appName} when syncs are performed via the UI or by {appName}",
@ -1112,7 +1114,7 @@
"MatchedToEpisodes": "Matched to Episodes", "MatchedToEpisodes": "Matched to Episodes",
"MatchedToSeason": "Matched to Season", "MatchedToSeason": "Matched to Season",
"MatchedToSeries": "Matched to Series", "MatchedToSeries": "Matched to Series",
"Max": "Max", "Maximum": "Maximum",
"MaximumLimits": "Maximum Limits", "MaximumLimits": "Maximum Limits",
"MaximumSingleEpisodeAge": "Maximum Single Episode Age", "MaximumSingleEpisodeAge": "Maximum Single Episode Age",
"MaximumSingleEpisodeAgeHelpText": "During a full season search only season packs will be allowed when the season's last episode is older than this setting. Standard series only. Use 0 to disable.", "MaximumSingleEpisodeAgeHelpText": "During a full season search only season packs will be allowed when the season's last episode is older than this setting. Standard series only. Use 0 to disable.",
@ -1126,7 +1128,6 @@
"MediaManagementSettingsLoadError": "Unable to load Media Management settings", "MediaManagementSettingsLoadError": "Unable to load Media Management settings",
"MediaManagementSettingsSummary": "Naming, file management settings and root folders", "MediaManagementSettingsSummary": "Naming, file management settings and root folders",
"Medium": "Medium", "Medium": "Medium",
"MegabytesPerMinute": "Megabytes Per Minute",
"Message": "Message", "Message": "Message",
"Metadata": "Metadata", "Metadata": "Metadata",
"MetadataLoadError": "Unable to load Metadata", "MetadataLoadError": "Unable to load Metadata",
@ -1151,7 +1152,7 @@
"MetadataXmbcSettingsSeriesMetadataHelpText": "tvshow.nfo with full series metadata", "MetadataXmbcSettingsSeriesMetadataHelpText": "tvshow.nfo with full series metadata",
"MetadataXmbcSettingsSeriesMetadataUrlHelpText": "Include TheTVDB show URL in tvshow.nfo (can be combined with 'Series Metadata')", "MetadataXmbcSettingsSeriesMetadataUrlHelpText": "Include TheTVDB show URL in tvshow.nfo (can be combined with 'Series Metadata')",
"MidseasonFinale": "Midseason Finale", "MidseasonFinale": "Midseason Finale",
"Min": "Min", "Minimum": "Minimum",
"MinimumAge": "Minimum Age", "MinimumAge": "Minimum Age",
"MinimumAgeHelpText": "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider.", "MinimumAgeHelpText": "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider.",
"MinimumCustomFormatScore": "Minimum Custom Format Score", "MinimumCustomFormatScore": "Minimum Custom Format Score",
@ -1159,6 +1160,8 @@
"MinimumFreeSpace": "Minimum Free Space", "MinimumFreeSpace": "Minimum Free Space",
"MinimumFreeSpaceHelpText": "Prevent import if it would leave less than this amount of disk space available", "MinimumFreeSpaceHelpText": "Prevent import if it would leave less than this amount of disk space available",
"MinimumLimits": "Minimum Limits", "MinimumLimits": "Minimum Limits",
"Minute": "minute",
"MinuteShorthand": "m",
"MinutesFortyFive": "45 Minutes: {fortyFive}", "MinutesFortyFive": "45 Minutes: {fortyFive}",
"MinutesSixty": "60 Minutes: {sixty}", "MinutesSixty": "60 Minutes: {sixty}",
"MinutesThirty": "30 Minutes: {thirty}", "MinutesThirty": "30 Minutes: {thirty}",
@ -1564,7 +1567,7 @@
"QualityCutoffNotMet": "Quality cutoff has not been met", "QualityCutoffNotMet": "Quality cutoff has not been met",
"QualityDefinitions": "Quality Definitions", "QualityDefinitions": "Quality Definitions",
"QualityDefinitionsLoadError": "Unable to load Quality Definitions", "QualityDefinitionsLoadError": "Unable to load Quality Definitions",
"QualityLimitsSeriesRuntimeHelpText": "Limits are automatically adjusted for the series runtime and number of episodes in the file.", "QualityDefinitionsSizeNotice": "Size restrictions have been moved to Quality Profiles",
"QualityProfile": "Quality Profile", "QualityProfile": "Quality Profile",
"QualityProfileInUseSeriesListCollection": "Can't delete a quality profile that is attached to a series, list, or collection", "QualityProfileInUseSeriesListCollection": "Can't delete a quality profile that is attached to a series, list, or collection",
"QualityProfiles": "Quality Profiles", "QualityProfiles": "Quality Profiles",

View File

@ -16,6 +16,9 @@ namespace NzbDrone.Core.Profiles.Qualities
public Quality Quality { get; set; } public Quality Quality { get; set; }
public List<QualityProfileQualityItem> Items { get; set; } public List<QualityProfileQualityItem> Items { get; set; }
public bool Allowed { get; set; } public bool Allowed { get; set; }
public double? MinSize { get; set; }
public double? MaxSize { get; set; }
public double? PreferredSize { get; set; }
public QualityProfileQualityItem() public QualityProfileQualityItem()
{ {

View File

@ -193,7 +193,14 @@ namespace NzbDrone.Core.Profiles.Qualities
{ {
var quality = group.First().Quality; var quality = group.First().Quality;
items.Add(new QualityProfileQualityItem { Quality = group.First().Quality, Allowed = allowed.Contains(quality) }); items.Add(new QualityProfileQualityItem
{
Quality = group.First().Quality,
Allowed = allowed.Contains(quality),
MinSize = group.First().MinSize,
MaxSize = group.First().MaxSize,
PreferredSize = group.First().PreferredSize
});
continue; continue;
} }
@ -206,7 +213,10 @@ namespace NzbDrone.Core.Profiles.Qualities
Items = group.Select(g => new QualityProfileQualityItem Items = group.Select(g => new QualityProfileQualityItem
{ {
Quality = g.Quality, Quality = g.Quality,
Allowed = groupAllowed Allowed = groupAllowed,
MinSize = g.MinSize,
MaxSize = g.MaxSize,
PreferredSize = g.PreferredSize
}).ToList(), }).ToList(),
Allowed = groupAllowed Allowed = groupAllowed
}); });

View File

@ -1,4 +1,4 @@
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.Qualities.Commands namespace NzbDrone.Core.Qualities.Commands
{ {

View File

@ -47,6 +47,7 @@ namespace NzbDrone.Core.Qualities
public void UpdateMany(List<QualityDefinition> qualityDefinitions) public void UpdateMany(List<QualityDefinition> qualityDefinitions)
{ {
_repo.UpdateMany(qualityDefinitions); _repo.UpdateMany(qualityDefinitions);
_cache.Clear();
} }
public List<QualityDefinition> All() public List<QualityDefinition> All()
@ -119,9 +120,6 @@ namespace NzbDrone.Core.Qualities
{ {
var existing = existingDefinitions.SingleOrDefault(d => d.Quality == definition.Quality); var existing = existingDefinitions.SingleOrDefault(d => d.Quality == definition.Quality);
existing.MinSize = definition.MinSize;
existing.MaxSize = definition.MaxSize;
existing.PreferredSize = definition.PreferredSize;
existing.Title = message.ResetTitles ? definition.Title : existing.Title; existing.Title = message.ResetTitles ? definition.Title : existing.Title;
updateList.Add(existing); updateList.Add(existing);

View File

@ -24,6 +24,9 @@ namespace Sonarr.Api.V3.Profiles.Quality
public NzbDrone.Core.Qualities.Quality Quality { get; set; } public NzbDrone.Core.Qualities.Quality Quality { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; } public List<QualityProfileQualityItemResource> Items { get; set; }
public bool Allowed { get; set; } public bool Allowed { get; set; }
public double? MinSize { get; set; }
public double? MaxSize { get; set; }
public double? PreferredSize { get; set; }
public QualityProfileQualityItemResource() public QualityProfileQualityItemResource()
{ {
@ -73,7 +76,10 @@ namespace Sonarr.Api.V3.Profiles.Quality
Name = model.Name, Name = model.Name,
Quality = model.Quality, Quality = model.Quality,
Items = model.Items.ConvertAll(ToResource), Items = model.Items.ConvertAll(ToResource),
Allowed = model.Allowed Allowed = model.Allowed,
MinSize = model.MinSize,
MaxSize = model.MaxSize,
PreferredSize = model.PreferredSize
}; };
} }
@ -120,7 +126,10 @@ namespace Sonarr.Api.V3.Profiles.Quality
Name = resource.Name, Name = resource.Name,
Quality = resource.Quality != null ? (NzbDrone.Core.Qualities.Quality)resource.Quality.Id : null, Quality = resource.Quality != null ? (NzbDrone.Core.Qualities.Quality)resource.Quality.Id : null,
Items = resource.Items.ConvertAll(ToModel), Items = resource.Items.ConvertAll(ToModel),
Allowed = resource.Allowed Allowed = resource.Allowed,
MinSize = resource.MinSize,
MaxSize = resource.MaxSize,
PreferredSize = resource.PreferredSize
}; };
} }

View File

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using Sonarr.Http.REST; using Sonarr.Http.REST;
@ -10,12 +10,6 @@ namespace Sonarr.Api.V3.Qualities
public Quality Quality { get; set; } public Quality Quality { get; set; }
public string Title { get; set; } public string Title { get; set; }
public int Weight { get; set; }
public double? MinSize { get; set; }
public double? MaxSize { get; set; }
public double? PreferredSize { get; set; }
} }
public static class QualityDefinitionResourceMapper public static class QualityDefinitionResourceMapper
@ -31,11 +25,7 @@ namespace Sonarr.Api.V3.Qualities
{ {
Id = model.Id, Id = model.Id,
Quality = model.Quality, Quality = model.Quality,
Title = model.Title, Title = model.Title
Weight = model.Weight,
MinSize = model.MinSize,
MaxSize = model.MaxSize,
PreferredSize = model.PreferredSize
}; };
} }
@ -50,11 +40,7 @@ namespace Sonarr.Api.V3.Qualities
{ {
Id = resource.Id, Id = resource.Id,
Quality = resource.Quality, Quality = resource.Quality,
Title = resource.Title, Title = resource.Title
Weight = resource.Weight,
MinSize = resource.MinSize,
MaxSize = resource.MaxSize,
PreferredSize = resource.PreferredSize
}; };
} }

View File

@ -1499,6 +1499,13 @@
"@types/history" "^4.7.11" "@types/history" "^4.7.11"
"@types/react" "*" "@types/react" "*"
"@types/react-slider@1.3.6":
version "1.3.6"
resolved "https://registry.yarnpkg.com/@types/react-slider/-/react-slider-1.3.6.tgz#6f5602be93ab1cb3d273428c87aa227ad2ff68ff"
integrity sha512-RS8XN5O159YQ6tu3tGZIQz1/9StMLTg/FCIPxwqh2gwVixJnlfIodtVx+fpXVMZHe7A58lAX1Q4XTgAGOQaCQg==
dependencies:
"@types/react" "*"
"@types/react-text-truncate@0.14.1": "@types/react-text-truncate@0.14.1":
version "0.14.1" version "0.14.1"
resolved "https://registry.yarnpkg.com/@types/react-text-truncate/-/react-text-truncate-0.14.1.tgz#3d24eca927e5fd1bfd789b047ae8ec53ba878b28" resolved "https://registry.yarnpkg.com/@types/react-text-truncate/-/react-text-truncate-0.14.1.tgz#3d24eca927e5fd1bfd789b047ae8ec53ba878b28"