New: Custom Formats

Co-Authored-By: ta264 <ta264@users.noreply.github.com>
This commit is contained in:
Qstick 2022-01-23 23:42:41 -06:00 committed by Mark McDowall
parent 909af6c874
commit b04b4000b8
173 changed files with 6401 additions and 1347 deletions

View File

@ -5,6 +5,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguage from 'Episode/EpisodeLanguage'; import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
@ -46,6 +47,7 @@ class BlocklistRow extends Component {
sourceTitle, sourceTitle,
language, language,
quality, quality,
customFormats,
date, date,
protocol, protocol,
indexer, indexer,
@ -120,6 +122,16 @@ class BlocklistRow extends Component {
); );
} }
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') { if (name === 'date') {
return ( return (
<RelativeDateCellConnector <RelativeDateCellConnector
@ -185,6 +197,7 @@ BlocklistRow.propTypes = {
sourceTitle: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired,
language: PropTypes.object.isRequired, language: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
date: PropTypes.string.isRequired, date: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired, protocol: PropTypes.string.isRequired,
indexer: PropTypes.string, indexer: PropTypes.string,

View File

@ -23,8 +23,8 @@ function HistoryDetails(props) {
const { const {
indexer, indexer,
releaseGroup, releaseGroup,
preferredWordScore,
seriesMatchType, seriesMatchType,
customFormatScore,
nzbInfoUrl, nzbInfoUrl,
downloadClient, downloadClient,
downloadClientName, downloadClientName,
@ -65,10 +65,10 @@ function HistoryDetails(props) {
} }
{ {
preferredWordScore && preferredWordScore !== '0' ? customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem <DescriptionListItem
title="Preferred Word Score" title="Custom Format Score"
data={formatPreferredWordScore(preferredWordScore)} data={formatPreferredWordScore(customFormatScore)}
/> : /> :
null null
} }
@ -163,7 +163,7 @@ function HistoryDetails(props) {
if (eventType === 'downloadFolderImported') { if (eventType === 'downloadFolderImported') {
const { const {
preferredWordScore, customFormatScore,
droppedPath, droppedPath,
importedPath importedPath
} = data; } = data;
@ -197,10 +197,10 @@ function HistoryDetails(props) {
} }
{ {
preferredWordScore && preferredWordScore !== '0' ? customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem <DescriptionListItem
title="Preferred Word Score" title="Custom Format Score"
data={formatPreferredWordScore(preferredWordScore)} data={formatPreferredWordScore(customFormatScore)}
/> : /> :
null null
} }
@ -211,7 +211,7 @@ function HistoryDetails(props) {
if (eventType === 'episodeFileDeleted') { if (eventType === 'episodeFileDeleted') {
const { const {
reason, reason,
preferredWordScore customFormatScore
} = data; } = data;
let reasonMessage = ''; let reasonMessage = '';
@ -243,10 +243,10 @@ function HistoryDetails(props) {
/> />
{ {
preferredWordScore && preferredWordScore !== '0' ? customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem <DescriptionListItem
title="Preferred Word Score" title="Custom Format Score"
data={formatPreferredWordScore(preferredWordScore)} data={formatPreferredWordScore(customFormatScore)}
/> : /> :
null null
} }

View File

@ -10,7 +10,7 @@
width: 80px; width: 80px;
} }
.preferredWordScore { .customFormatScore {
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 55px; width: 55px;

View File

@ -5,6 +5,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import episodeEntities from 'Episode/episodeEntities'; import episodeEntities from 'Episode/episodeEntities';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguage from 'Episode/EpisodeLanguage'; import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
@ -61,6 +62,7 @@ class HistoryRow extends Component {
language, language,
languageCutoffNotMet, languageCutoffNotMet,
quality, quality,
customFormats,
qualityCutoffNotMet, qualityCutoffNotMet,
eventType, eventType,
sourceTitle, sourceTitle,
@ -164,6 +166,16 @@ class HistoryRow extends Component {
); );
} }
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') { if (name === 'date') {
return ( return (
<RelativeDateCellConnector <RelativeDateCellConnector
@ -195,13 +207,13 @@ class HistoryRow extends Component {
); );
} }
if (name === 'preferredWordScore') { if (name === 'customFormatScore') {
return ( return (
<TableRowCell <TableRowCell
key={name} key={name}
className={styles.preferredWordScore} className={styles.customFormatScore}
> >
{formatPreferredWordScore(data.preferredWordScore)} {formatPreferredWordScore(data.customFormatScore)}
</TableRowCell> </TableRowCell>
); );
} }
@ -269,6 +281,7 @@ HistoryRow.propTypes = {
language: PropTypes.object.isRequired, language: PropTypes.object.isRequired,
languageCutoffNotMet: PropTypes.bool.isRequired, languageCutoffNotMet: PropTypes.bool.isRequired,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
qualityCutoffNotMet: PropTypes.bool.isRequired, qualityCutoffNotMet: PropTypes.bool.isRequired,
eventType: PropTypes.string.isRequired, eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired,

View File

@ -8,6 +8,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguage from 'Episode/EpisodeLanguage'; import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
@ -89,6 +90,7 @@ class QueueRow extends Component {
episode, episode,
language, language,
quality, quality,
customFormats,
protocol, protocol,
indexer, indexer,
outputPath, outputPath,
@ -247,6 +249,16 @@ class QueueRow extends Component {
); );
} }
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'protocol') { if (name === 'protocol') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
@ -400,6 +412,7 @@ QueueRow.propTypes = {
episode: PropTypes.object, episode: PropTypes.object,
language: PropTypes.object.isRequired, language: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
protocol: PropTypes.string.isRequired, protocol: PropTypes.string.isRequired,
indexer: PropTypes.string, indexer: PropTypes.string,
outputPath: PropTypes.string, outputPath: PropTypes.string,

View File

@ -13,6 +13,7 @@ import SeasonPassConnector from 'SeasonPass/SeasonPassConnector';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
import SeriesEditorConnector from 'Series/Editor/SeriesEditorConnector'; import SeriesEditorConnector from 'Series/Editor/SeriesEditorConnector';
import SeriesIndexConnector from 'Series/Index/SeriesIndexConnector'; import SeriesIndexConnector from 'Series/Index/SeriesIndexConnector';
import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
@ -161,6 +162,11 @@ function AppRoutes(props) {
component={QualityConnector} component={QualityConnector}
/> />
<Route
path="/settings/customformats"
component={CustomFormatSettingsConnector}
/>
<Route <Route
path="/settings/indexers" path="/settings/indexers"
component={IndexerSettingsConnector} component={IndexerSettingsConnector}

View File

@ -23,6 +23,7 @@ import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
import SeriesTypeSelectInput from './SeriesTypeSelectInput'; import SeriesTypeSelectInput from './SeriesTypeSelectInput';
import TagInputConnector from './TagInputConnector'; import TagInputConnector from './TagInputConnector';
import TagSelectInputConnector from './TagSelectInputConnector'; import TagSelectInputConnector from './TagSelectInputConnector';
import TextArea from './TextArea';
import TextInput from './TextInput'; import TextInput from './TextInput';
import TextTagInputConnector from './TextTagInputConnector'; import TextTagInputConnector from './TextTagInputConnector';
import UMaskInput from './UMaskInput'; import UMaskInput from './UMaskInput';
@ -87,6 +88,9 @@ function getComponent(type) {
case inputTypes.TAG: case inputTypes.TAG:
return TagInputConnector; return TagInputConnector;
case inputTypes.TEXT_AREA:
return TextArea;
case inputTypes.TEXT_TAG: case inputTypes.TEXT_TAG:
return TextTagInputConnector; return TextTagInputConnector;

View File

@ -0,0 +1,19 @@
.input {
composes: input from '~Components/Form/Input.css';
flex-grow: 1;
min-height: 200px;
resize: vertical;
}
.readOnly {
background-color: #eee;
}
.hasError {
composes: hasError from '~Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from '~Components/Form/Input.css';
}

View File

@ -0,0 +1,172 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './TextArea.css';
class TextArea extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._input = null;
this._selectionStart = null;
this._selectionEnd = null;
this._selectionTimeout = null;
this._isMouseTarget = false;
}
componentDidMount() {
window.addEventListener('mouseup', this.onDocumentMouseUp);
}
componentWillUnmount() {
window.removeEventListener('mouseup', this.onDocumentMouseUp);
if (this._selectionTimeout) {
this._selectionTimeout = clearTimeout(this._selectionTimeout);
}
}
//
// Control
setInputRef = (ref) => {
this._input = ref;
};
selectionChange() {
if (this._selectionTimeout) {
this._selectionTimeout = clearTimeout(this._selectionTimeout);
}
this._selectionTimeout = setTimeout(() => {
const selectionStart = this._input.selectionStart;
const selectionEnd = this._input.selectionEnd;
const selectionChanged = (
this._selectionStart !== selectionStart ||
this._selectionEnd !== selectionEnd
);
this._selectionStart = selectionStart;
this._selectionEnd = selectionEnd;
if (this.props.onSelectionChange && selectionChanged) {
this.props.onSelectionChange(selectionStart, selectionEnd);
}
}, 10);
}
//
// Listeners
onChange = (event) => {
const {
name,
onChange
} = this.props;
const payload = {
name,
value: event.target.value
};
onChange(payload);
};
onFocus = (event) => {
if (this.props.onFocus) {
this.props.onFocus(event);
}
this.selectionChange();
};
onKeyUp = () => {
this.selectionChange();
};
onMouseDown = () => {
this._isMouseTarget = true;
};
onMouseUp = () => {
this.selectionChange();
};
onDocumentMouseUp = () => {
if (this._isMouseTarget) {
this.selectionChange();
}
this._isMouseTarget = false;
};
//
// Render
render() {
const {
className,
readOnly,
autoFocus,
placeholder,
name,
value,
hasError,
hasWarning,
onBlur
} = this.props;
return (
<textarea
ref={this.setInputRef}
readOnly={readOnly}
autoFocus={autoFocus}
placeholder={placeholder}
className={classNames(
className,
readOnly && styles.readOnly,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={this.onChange}
onFocus={this.onFocus}
onBlur={onBlur}
onKeyUp={this.onKeyUp}
onMouseDown={this.onMouseDown}
onMouseUp={this.onMouseUp}
/>
);
}
}
TextArea.propTypes = {
className: PropTypes.string.isRequired,
readOnly: PropTypes.bool,
autoFocus: PropTypes.bool,
placeholder: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onSelectionChange: PropTypes.func
};
TextArea.defaultProps = {
className: styles.input,
type: 'text',
readOnly: false,
autoFocus: false,
value: ''
};
export default TextArea;

View File

@ -17,6 +17,7 @@ class ClipboardButton extends Component {
this._id = getUniqueElememtId(); this._id = getUniqueElememtId();
this._successTimeout = null; this._successTimeout = null;
this._testResultTimeout = null;
this.state = { this.state = {
showSuccess: false, showSuccess: false,
@ -26,7 +27,8 @@ class ClipboardButton extends Component {
componentDidMount() { componentDidMount() {
this._clipboard = new Clipboard(`#${this._id}`, { this._clipboard = new Clipboard(`#${this._id}`, {
text: () => this.props.value text: () => this.props.value,
container: document.getElementById(this._id)
}); });
this._clipboard.on('success', this.onSuccess); this._clipboard.on('success', this.onSuccess);
@ -47,6 +49,10 @@ class ClipboardButton extends Component {
if (this._clipboard) { if (this._clipboard) {
this._clipboard.destroy(); this._clipboard.destroy();
} }
if (this._testResultTimeout) {
clearTimeout(this._testResultTimeout);
}
} }
// //
@ -80,6 +86,7 @@ class ClipboardButton extends Component {
render() { render() {
const { const {
value, value,
className,
...otherProps ...otherProps
} = this.props; } = this.props;
@ -95,7 +102,7 @@ class ClipboardButton extends Component {
return ( return (
<FormInputButton <FormInputButton
id={this._id} id={this._id}
className={styles.button} className={className}
{...otherProps} {...otherProps}
> >
<span className={showStateIcon ? styles.showStateIcon : undefined}> <span className={showStateIcon ? styles.showStateIcon : undefined}>
@ -121,7 +128,12 @@ class ClipboardButton extends Component {
} }
ClipboardButton.propTypes = { ClipboardButton.propTypes = {
className: PropTypes.string.isRequired,
value: PropTypes.string.isRequired value: PropTypes.string.isRequired
}; };
ClipboardButton.defaultProps = {
className: styles.button
};
export default ClipboardButton; export default ClipboardButton;

View File

@ -103,6 +103,10 @@ const links = [
title: 'Quality', title: 'Quality',
to: '/settings/quality' to: '/settings/quality'
}, },
{
title: 'Custom Formats',
to: '/settings/customformats'
},
{ {
title: 'Indexers', title: 'Indexers',
to: '/settings/indexers' to: '/settings/indexers'

View File

@ -0,0 +1,33 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
function EpisodeFormats({ formats }) {
return (
<div>
{
formats.map((format) => {
return (
<Label
key={format.id}
kind={kinds.INFO}
>
{format.name}
</Label>
);
})
}
</div>
);
}
EpisodeFormats.propTypes = {
formats: PropTypes.arrayOf(PropTypes.object).isRequired
};
EpisodeFormats.defaultProps = {
formats: []
};
export default EpisodeFormats;

View File

@ -1,8 +1,10 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import { icons } from 'Helpers/Props';
import EpisodeHistoryRow from './EpisodeHistoryRow'; import EpisodeHistoryRow from './EpisodeHistoryRow';
const columns = [ const columns = [
@ -35,6 +37,15 @@ const columns = [
label: 'Details', label: 'Details',
isVisible: true isVisible: true
}, },
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: 'Custom format score'
}),
isSortable: true,
isVisible: true
},
{ {
name: 'actions', name: 'actions',
label: 'Actions', label: 'Actions',

View File

@ -3,15 +3,18 @@ import React, { Component } from 'react';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeLanguage from 'Episode/EpisodeLanguage'; import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import styles from './EpisodeHistoryRow.css'; import styles from './EpisodeHistoryRow.css';
function getTitle(eventType) { function getTitle(eventType) {
@ -66,6 +69,7 @@ class EpisodeHistoryRow extends Component {
languageCutoffNotMet, languageCutoffNotMet,
quality, quality,
qualityCutoffNotMet, qualityCutoffNotMet,
customFormats,
date, date,
data data
} = this.props; } = this.props;
@ -122,6 +126,28 @@ class EpisodeHistoryRow extends Component {
/> />
</TableRowCell> </TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={
formatPreferredWordScore(data.customFormatScore)
}
tooltip={
<div>
{
customFormats.map((format) => {
return (
<Label key={format.id}>
{format.name}
</Label>
);
})
}
</div>
}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
<TableRowCell className={styles.actions}> <TableRowCell className={styles.actions}>
{ {
eventType === 'grabbed' && eventType === 'grabbed' &&
@ -155,6 +181,7 @@ EpisodeHistoryRow.propTypes = {
languageCutoffNotMet: PropTypes.bool.isRequired, languageCutoffNotMet: PropTypes.bool.isRequired,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired, qualityCutoffNotMet: PropTypes.bool.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
date: PropTypes.string.isRequired, date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired, data: PropTypes.object.isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired onMarkAsFailedPress: PropTypes.func.isRequired

View File

@ -52,6 +52,7 @@ import {
faEye as fasEye, faEye as fasEye,
faFastBackward as fasFastBackward, faFastBackward as fasFastBackward,
faFastForward as fasFastForward, faFastForward as fasFastForward,
faFileExport as fasFileExport,
faFileInvoice as farFileInvoice, faFileInvoice as farFileInvoice,
faFilter as fasFilter, faFilter as fasFilter,
faFolderOpen as fasFolderOpen, faFolderOpen as fasFolderOpen,
@ -133,6 +134,7 @@ export const EDIT = fasWrench;
export const EPISODE_FILE = farFileVideo; export const EPISODE_FILE = farFileVideo;
export const EXPAND = fasChevronCircleDown; export const EXPAND = fasChevronCircleDown;
export const EXPAND_INDETERMINATE = fasChevronCircleRight; export const EXPAND_INDETERMINATE = fasChevronCircleRight;
export const EXPORT = fasFileExport;
export const EXTERNAL_LINK = fasExternalLinkAlt; export const EXTERNAL_LINK = fasExternalLinkAlt;
export const FATAL = fasTimesCircle; export const FATAL = fasTimesCircle;
export const FILE = farFile; export const FILE = farFile;

View File

@ -18,6 +18,7 @@ export const DYNAMIC_SELECT = 'dynamicSelect';
export const SERIES_TYPE_SELECT = 'seriesTypeSelect'; export const SERIES_TYPE_SELECT = 'seriesTypeSelect';
export const TAG = 'tag'; export const TAG = 'tag';
export const TEXT = 'text'; export const TEXT = 'text';
export const TEXT_AREA = 'textArea';
export const TEXT_TAG = 'textTag'; export const TEXT_TAG = 'textTag';
export const TAG_SELECT = 'tagSelect'; export const TAG_SELECT = 'tagSelect';
export const UMASK = 'umask'; export const UMASK = 'umask';
@ -43,6 +44,7 @@ export const all = [
SERIES_TYPE_SELECT, SERIES_TYPE_SELECT,
TAG, TAG,
TEXT, TEXT,
TEXT_AREA,
TEXT_TAG, TEXT_TAG,
TAG_SELECT, TAG_SELECT,
UMASK UMASK

View File

@ -62,10 +62,10 @@ const columns = [
isVisible: true isVisible: true
}, },
{ {
name: 'preferredWordScore', name: 'customFormatScore',
label: React.createElement(Icon, { label: React.createElement(Icon, {
name: icons.SCORE, name: icons.SCORE,
title: 'Preferred word score' title: 'Custom format score'
}), }),
isSortable: true, isSortable: true,
isVisible: true isVisible: true

View File

@ -34,7 +34,7 @@
width: 100px; width: 100px;
} }
.preferredWordScore { .customFormatScore {
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 55px; width: 55px;

View File

@ -2,12 +2,14 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeLanguage from 'Episode/EpisodeLanguage'; import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
@ -115,7 +117,8 @@ class InteractiveSearchRow extends Component {
leechers, leechers,
quality, quality,
language, language,
preferredWordScore, customFormatScore,
customFormats,
sceneMapping, sceneMapping,
seasonNumber, seasonNumber,
episodeNumbers, episodeNumbers,
@ -193,8 +196,26 @@ class InteractiveSearchRow extends Component {
<EpisodeQuality quality={quality} /> <EpisodeQuality quality={quality} />
</TableRowCell> </TableRowCell>
<TableRowCell className={styles.preferredWordScore}> <TableRowCell className={styles.customFormatScore}>
{formatPreferredWordScore(preferredWordScore)} <Tooltip
anchor={
formatPreferredWordScore(customFormatScore)
}
tooltip={
<div>
{
customFormats.map((format) => {
return (
<Label key={format.id}>
{format.name}
</Label>
);
})
}
</div>
}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell> </TableRowCell>
<TableRowCell className={styles.rejected}> <TableRowCell className={styles.rejected}>
@ -266,7 +287,8 @@ InteractiveSearchRow.propTypes = {
leechers: PropTypes.number, leechers: PropTypes.number,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
language: PropTypes.object.isRequired, language: PropTypes.object.isRequired,
preferredWordScore: PropTypes.number.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
sceneMapping: PropTypes.object, sceneMapping: PropTypes.object,
seasonNumber: PropTypes.number, seasonNumber: PropTypes.number,
episodeNumbers: PropTypes.arrayOf(PropTypes.number), episodeNumbers: PropTypes.arrayOf(PropTypes.number),

View File

@ -4,6 +4,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeNumber from 'Episode/EpisodeNumber'; import EpisodeNumber from 'Episode/EpisodeNumber';
import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector'; import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector'; import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
@ -68,6 +69,7 @@ class EpisodeRow extends Component {
episodeFileRelativePath, episodeFileRelativePath,
episodeFileSize, episodeFileSize,
releaseGroup, releaseGroup,
customFormats,
alternateTitles, alternateTitles,
columns columns
} = this.props; } = this.props;
@ -168,6 +170,16 @@ class EpisodeRow extends Component {
); );
} }
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<EpisodeFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'language') { if (name === 'language') {
return ( return (
<TableRowCell <TableRowCell
@ -328,6 +340,7 @@ EpisodeRow.propTypes = {
episodeFileRelativePath: PropTypes.string, episodeFileRelativePath: PropTypes.string,
episodeFileSize: PropTypes.number, episodeFileSize: PropTypes.number,
releaseGroup: PropTypes.string, releaseGroup: PropTypes.string,
customFormats: PropTypes.arrayOf(PropTypes.object),
mediaInfo: PropTypes.object, mediaInfo: PropTypes.object,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
@ -335,7 +348,8 @@ EpisodeRow.propTypes = {
}; };
EpisodeRow.defaultProps = { EpisodeRow.defaultProps = {
alternateTitles: [] alternateTitles: [],
customFormats: []
}; };
export default EpisodeRow; export default EpisodeRow;

View File

@ -18,6 +18,7 @@ function createMapStateToProps() {
episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null, episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null,
episodeFileSize: episodeFile ? episodeFile.size : null, episodeFileSize: episodeFile ? episodeFile.size : null,
releaseGroup: episodeFile ? episodeFile.releaseGroup : null, releaseGroup: episodeFile ? episodeFile.releaseGroup : null,
customFormats: episodeFile ? episodeFile.customFormats : [],
alternateTitles: series.alternateTitles alternateTitles: series.alternateTitles
}; };
} }

View File

@ -1,5 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
@ -8,6 +9,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import { icons } from 'Helpers/Props';
import SeasonNumber from 'Season/SeasonNumber'; import SeasonNumber from 'Season/SeasonNumber';
import SeriesHistoryRowConnector from './SeriesHistoryRowConnector'; import SeriesHistoryRowConnector from './SeriesHistoryRowConnector';
@ -46,6 +48,15 @@ const columns = [
label: 'Details', label: 'Details',
isVisible: true isVisible: true
}, },
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: 'Custom format score'
}),
isSortable: true,
isVisible: true
},
{ {
name: 'actions', name: 'actions',
label: 'Actions', label: 'Actions',

View File

@ -3,17 +3,20 @@ import React, { Component } from 'react';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeLanguage from 'Episode/EpisodeLanguage'; import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeNumber from 'Episode/EpisodeNumber'; import EpisodeNumber from 'Episode/EpisodeNumber';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import styles from './SeriesHistoryRow.css'; import styles from './SeriesHistoryRow.css';
function getTitle(eventType) { function getTitle(eventType) {
@ -68,6 +71,7 @@ class SeriesHistoryRow extends Component {
languageCutoffNotMet, languageCutoffNotMet,
quality, quality,
qualityCutoffNotMet, qualityCutoffNotMet,
customFormats,
date, date,
data, data,
fullSeries, fullSeries,
@ -142,6 +146,28 @@ class SeriesHistoryRow extends Component {
/> />
</TableRowCell> </TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={
formatPreferredWordScore(data.customFormatScore)
}
tooltip={
<div>
{
customFormats.map((format) => {
return (
<Label key={format.id}>
{format.name}
</Label>
);
})
}
</div>
}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
<TableRowCell className={styles.actions}> <TableRowCell className={styles.actions}>
{ {
eventType === 'grabbed' && eventType === 'grabbed' &&
@ -175,6 +201,7 @@ SeriesHistoryRow.propTypes = {
languageCutoffNotMet: PropTypes.bool.isRequired, languageCutoffNotMet: PropTypes.bool.isRequired,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired, qualityCutoffNotMet: PropTypes.bool.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
date: PropTypes.string.isRequired, date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired, data: PropTypes.object.isRequired,
fullSeries: PropTypes.bool.isRequired, fullSeries: PropTypes.bool.isRequired,

View File

@ -0,0 +1,32 @@
import React, { Component } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
class CustomFormatSettingsConnector extends Component {
//
// Render
render() {
return (
<PageContent title="Custom Format Settings">
<SettingsToolbarConnector
showSave={false}
/>
<PageContentBody>
<DndProvider backend={HTML5Backend}>
<CustomFormatsConnector />
</DndProvider>
</PageContentBody>
</PageContent>
);
}
}
export default CustomFormatSettingsConnector;

View File

@ -0,0 +1,38 @@
.customFormat {
composes: card from '~Components/Card.css';
width: 300px;
}
.nameContainer {
display: flex;
justify-content: space-between;
}
.name {
@add-mixin truncate;
margin-bottom: 20px;
font-weight: 300;
font-size: 24px;
}
.cloneButton {
composes: button from '~Components/Link/IconButton.css';
height: 36px;
}
.formats {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
pointer-events: all;
}
.tooltipLabel {
composes: label from '~Components/Label.css';
margin: 0;
border: none;
}

View File

@ -0,0 +1,173 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, kinds } from 'Helpers/Props';
import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
import ExportCustomFormatModal from './ExportCustomFormatModal';
import styles from './CustomFormat.css';
class CustomFormat extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditCustomFormatModalOpen: false,
isExportCustomFormatModalOpen: false,
isDeleteCustomFormatModalOpen: false
};
}
//
// Listeners
onEditCustomFormatPress = () => {
this.setState({ isEditCustomFormatModalOpen: true });
};
onEditCustomFormatModalClose = () => {
this.setState({ isEditCustomFormatModalOpen: false });
};
onExportCustomFormatPress = () => {
this.setState({ isExportCustomFormatModalOpen: true });
};
onExportCustomFormatModalClose = () => {
this.setState({ isExportCustomFormatModalOpen: false });
};
onDeleteCustomFormatPress = () => {
this.setState({
isEditCustomFormatModalOpen: false,
isDeleteCustomFormatModalOpen: true
});
};
onDeleteCustomFormatModalClose = () => {
this.setState({ isDeleteCustomFormatModalOpen: false });
};
onConfirmDeleteCustomFormat = () => {
this.props.onConfirmDeleteCustomFormat(this.props.id);
};
onCloneCustomFormatPress = () => {
const {
id,
onCloneCustomFormatPress
} = this.props;
onCloneCustomFormatPress(id);
};
//
// Render
render() {
const {
id,
name,
specifications,
isDeleting
} = this.props;
return (
<Card
className={styles.customFormat}
overlayContent={true}
onPress={this.onEditCustomFormatPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<div>
<IconButton
className={styles.cloneButton}
title="Clone Custom Format"
name={icons.CLONE}
onPress={this.onCloneCustomFormatPress}
/>
<IconButton
className={styles.cloneButton}
title="Export Custom Format"
name={icons.EXPORT}
onPress={this.onExportCustomFormatPress}
/>
</div>
</div>
<div>
{
specifications.map((item, index) => {
if (!item) {
return null;
}
let kind = kinds.DEFAULT;
if (item.required) {
kind = kinds.SUCCESS;
}
if (item.negate) {
kind = kinds.DANGER;
}
return (
<Label
key={index}
kind={kind}
>
{item.name}
</Label>
);
})
}
</div>
<EditCustomFormatModalConnector
id={id}
isOpen={this.state.isEditCustomFormatModalOpen}
onModalClose={this.onEditCustomFormatModalClose}
onDeleteCustomFormatPress={this.onDeleteCustomFormatPress}
/>
<ExportCustomFormatModal
id={id}
isOpen={this.state.isExportCustomFormatModalOpen}
onModalClose={this.onExportCustomFormatModalClose}
/>
<ConfirmModal
isOpen={this.state.isDeleteCustomFormatModalOpen}
kind={kinds.DANGER}
title="Delete Custom Format"
message={`Are you sure you want to delete the custom format '${name}'?`}
confirmLabel="Delete"
isSpinning={isDeleting}
onConfirm={this.onConfirmDeleteCustomFormat}
onCancel={this.onDeleteCustomFormatModalClose}
/>
</Card>
);
}
}
CustomFormat.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
specifications: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
onConfirmDeleteCustomFormat: PropTypes.func.isRequired,
onCloneCustomFormatPress: PropTypes.func.isRequired
};
export default CustomFormat;

View File

@ -0,0 +1,21 @@
.customFormats {
display: flex;
flex-wrap: wrap;
}
.addCustomFormat {
composes: customFormat from '~./CustomFormat.css';
background-color: var(--cardAlternateBackgroundColor);
color: var(--gray);
text-align: center;
font-size: 45px;
}
.center {
display: inline-block;
padding: 5px 20px 0;
border: 1px solid var(--borderColor);
border-radius: 4px;
background-color: var(--cardCenterBackgroundColor);
}

View File

@ -0,0 +1,115 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import CustomFormat from './CustomFormat';
import EditCustomFormatModalConnector from './EditCustomFormatModalConnector';
import styles from './CustomFormats.css';
class CustomFormats extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isCustomFormatModalOpen: false,
tagsFromId: undefined
};
}
//
// Listeners
onCloneCustomFormatPress = (id) => {
this.props.onCloneCustomFormatPress(id);
this.setState({
isCustomFormatModalOpen: true,
tagsFromId: id
});
};
onEditCustomFormatPress = () => {
this.setState({ isCustomFormatModalOpen: true });
};
onModalClose = () => {
this.setState({
isCustomFormatModalOpen: false,
tagsFromId: undefined
});
};
//
// Render
render() {
const {
items,
isDeleting,
onConfirmDeleteCustomFormat,
onCloneCustomFormatPress,
...otherProps
} = this.props;
return (
<FieldSet legend="Custom Formats">
<PageSectionContent
errorMessage="Unable to load custom formats"
{...otherProps}c={true}
>
<div className={styles.customFormats}>
{
items.map((item) => {
return (
<CustomFormat
key={item.id}
{...item}
isDeleting={isDeleting}
onConfirmDeleteCustomFormat={onConfirmDeleteCustomFormat}
onCloneCustomFormatPress={this.onCloneCustomFormatPress}
/>
);
})
}
<Card
className={styles.addCustomFormat}
onPress={this.onEditCustomFormatPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
<EditCustomFormatModalConnector
isOpen={this.state.isCustomFormatModalOpen}
tagsFromId={this.state.tagsFromId}
onModalClose={this.onModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
CustomFormats.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteCustomFormat: PropTypes.func.isRequired,
onCloneCustomFormatPress: PropTypes.func.isRequired
};
export default CustomFormats;

View File

@ -0,0 +1,63 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { cloneCustomFormat, deleteCustomFormat, fetchCustomFormats } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByName from 'Utilities/Array/sortByName';
import CustomFormats from './CustomFormats';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.customFormats', sortByName),
(customFormats) => customFormats
);
}
const mapDispatchToProps = {
dispatchFetchCustomFormats: fetchCustomFormats,
dispatchDeleteCustomFormat: deleteCustomFormat,
dispatchCloneCustomFormat: cloneCustomFormat
};
class CustomFormatsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchCustomFormats();
}
//
// Listeners
onConfirmDeleteCustomFormat = (id) => {
this.props.dispatchDeleteCustomFormat({ id });
};
onCloneCustomFormatPress = (id) => {
this.props.dispatchCloneCustomFormat({ id });
};
//
// Render
render() {
return (
<CustomFormats
onConfirmDeleteCustomFormat={this.onConfirmDeleteCustomFormat}
onCloneCustomFormatPress={this.onCloneCustomFormatPress}
{...this.props}
/>
);
}
}
CustomFormatsConnector.propTypes = {
dispatchFetchCustomFormats: PropTypes.func.isRequired,
dispatchDeleteCustomFormat: PropTypes.func.isRequired,
dispatchCloneCustomFormat: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CustomFormatsConnector);

View File

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

View File

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

View File

@ -0,0 +1,27 @@
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: auto;
}
.rightButtons {
justify-content: flex-end;
margin-right: auto;
}
.addSpecification {
composes: customFormat from '~./CustomFormat.css';
background-color: var(--cardAlternateBackgroundColor);
color: var(--gray);
text-align: center;
font-size: 45px;
}
.center {
display: inline-block;
padding: 5px 20px 0;
border: 1px solid var(--borderColor);
border-radius: 4px;
background-color: var(--cardCenterBackgroundColor);
}

View File

@ -0,0 +1,256 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import ImportCustomFormatModal from './ImportCustomFormatModal';
import AddSpecificationModal from './Specifications/AddSpecificationModal';
import EditSpecificationModalConnector from './Specifications/EditSpecificationModalConnector';
import Specification from './Specifications/Specification';
import styles from './EditCustomFormatModalContent.css';
class EditCustomFormatModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddSpecificationModalOpen: false,
isEditSpecificationModalOpen: false,
isImportCustomFormatModalOpen: false
};
}
//
// Listeners
onAddSpecificationPress = () => {
this.setState({ isAddSpecificationModalOpen: true });
};
onAddSpecificationModalClose = ({ specificationSelected = false } = {}) => {
this.setState({
isAddSpecificationModalOpen: false,
isEditSpecificationModalOpen: specificationSelected
});
};
onEditSpecificationModalClose = () => {
this.setState({ isEditSpecificationModalOpen: false });
};
onImportPress = () => {
this.setState({ isImportCustomFormatModalOpen: true });
};
onImportCustomFormatModalClose = () => {
this.setState({ isImportCustomFormatModalOpen: false });
};
//
// Render
render() {
const {
isFetching,
error,
isSaving,
saveError,
item,
specificationsPopulated,
specifications,
onInputChange,
onSavePress,
onModalClose,
onDeleteCustomFormatPress,
onCloneSpecificationPress,
onConfirmDeleteSpecification,
...otherProps
} = this.props;
const {
isAddSpecificationModalOpen,
isEditSpecificationModalOpen,
isImportCustomFormatModalOpen
} = this.state;
const {
id,
name,
includeCustomFormatWhenRenaming
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Custom Format' : 'Add Custom Format'}
</ModalHeader>
<ModalBody>
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
{'Unable to add a new custom format, please try again.'}
</div>
}
{
!isFetching && !error && specificationsPopulated &&
<div>
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>
Name
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{'Include Custom Format when Renaming'}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeCustomFormatWhenRenaming"
helpText={'Include in {Custom Formats} renaming format'}
{...includeCustomFormatWhenRenaming}
onChange={onInputChange}
/>
</FormGroup>
</Form>
<FieldSet legend={'Conditions'}>
<div className={styles.customFormats}>
{
specifications.map((tag) => {
return (
<Specification
key={tag.id}
{...tag}
onCloneSpecificationPress={onCloneSpecificationPress}
onConfirmDeleteSpecification={onConfirmDeleteSpecification}
/>
);
})
}
<Card
className={styles.addSpecification}
onPress={this.onAddSpecificationPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
</FieldSet>
<AddSpecificationModal
isOpen={isAddSpecificationModalOpen}
onModalClose={this.onAddSpecificationModalClose}
/>
<EditSpecificationModalConnector
isOpen={isEditSpecificationModalOpen}
onModalClose={this.onEditSpecificationModalClose}
/>
<ImportCustomFormatModal
isOpen={isImportCustomFormatModalOpen}
onModalClose={this.onImportCustomFormatModalClose}
/>
</div>
}
</div>
</ModalBody>
<ModalFooter>
<div className={styles.rightButtons}>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteCustomFormatPress}
>
Delete
</Button>
}
<Button
className={styles.deleteButton}
onPress={this.onImportPress}
>
Import
</Button>
</div>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
}
EditCustomFormatModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
specificationsPopulated: PropTypes.bool.isRequired,
specifications: PropTypes.arrayOf(PropTypes.object),
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onContentHeightChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteCustomFormatPress: PropTypes.func,
onCloneSpecificationPress: PropTypes.func.isRequired,
onConfirmDeleteSpecification: PropTypes.func.isRequired
};
export default EditCustomFormatModalContent;

View File

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

View File

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

View File

@ -0,0 +1,5 @@
.button {
composes: button from '~Components/Link/Button.css';
position: relative;
}

View File

@ -0,0 +1,84 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import ClipboardButton from 'Components/Link/ClipboardButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import styles from './ExportCustomFormatModalContent.css';
class ExportCustomFormatModalContent extends Component {
//
// Render
render() {
const {
isFetching,
error,
json,
specificationsPopulated,
onModalClose
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Export Custom Format
</ModalHeader>
<ModalBody>
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
Unable to load custom formats
</div>
}
{
!isFetching && !error && specificationsPopulated &&
<div>
<pre>
{json}
</pre>
</div>
}
</div>
</ModalBody>
<ModalFooter>
<ClipboardButton
className={styles.button}
value={json}
title="Copy to clipboard"
kind={kinds.DEFAULT}
/>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
ExportCustomFormatModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
json: PropTypes.string.isRequired,
specificationsPopulated: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ExportCustomFormatModalContent;

View File

@ -0,0 +1,83 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchCustomFormatSpecifications } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import ExportCustomFormatModalContent from './ExportCustomFormatModalContent';
const omittedProperties = ['id', 'implementationName', 'infoLink'];
function replacer(key, value) {
if (omittedProperties.includes(key)) {
return undefined;
}
// provider fields
if (key === 'fields') {
return value.reduce((acc, cur) => {
acc[cur.name] = cur.value;
return acc;
}, {});
}
// regular setting values
if (value.hasOwnProperty('value')) {
return value.value;
}
return value;
}
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('customFormats'),
(state) => state.settings.customFormatSpecifications,
(advancedSettings, customFormat, specifications) => {
const json = customFormat.item ? JSON.stringify(customFormat.item, replacer, 2) : '';
return {
advancedSettings,
...customFormat,
json,
specificationsPopulated: specifications.isPopulated,
specifications: specifications.items
};
}
);
}
const mapDispatchToProps = {
fetchCustomFormatSpecifications
};
class ExportCustomFormatModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
id
} = this.props;
this.props.fetchCustomFormatSpecifications({ id });
}
//
// Render
render() {
return (
<ExportCustomFormatModalContent
{...this.props}
/>
);
}
}
ExportCustomFormatModalContentConnector.propTypes = {
id: PropTypes.number,
fetchCustomFormatSpecifications: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ExportCustomFormatModalContentConnector);

View File

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

View File

@ -0,0 +1,5 @@
.input {
composes: input from '~Components/Form/TextArea.css';
font-family: $monoSpaceFontFamily;
}

View File

@ -0,0 +1,151 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, sizes } from 'Helpers/Props';
import styles from './ImportCustomFormatModalContent.css';
class ImportCustomFormatModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._importTimeout = null;
this.state = {
json: '',
isSpinning: false,
parseError: null
};
}
componentWillUnmount() {
if (this._importTimeout) {
clearTimeout(this._importTimeout);
}
}
//
// Control
onChange = (event) => {
this.setState({ json: event.value });
};
onImportPress = () => {
this.setState({ isSpinning: true });
// this is a bodge as we need to register a isSpinning: true to get the spinner button to update
this._importTimeout = setTimeout(this.doImport, 250);
};
doImport = () => {
const parseError = this.props.onImportPress(this.state.json);
this.setState({
parseError,
isSpinning: false
});
if (!parseError) {
this.props.onModalClose();
}
};
//
// Render
render() {
const {
isFetching,
error,
specificationsPopulated,
onModalClose
} = this.props;
const {
json,
isSpinning,
parseError
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Import Custom Format
</ModalHeader>
<ModalBody>
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
Unable to load custom formats
</div>
}
{
!isFetching && !error && specificationsPopulated &&
<Form>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>
Custom Format JSON
</FormLabel>
<FormInputGroup
key={0}
inputClassName={styles.input}
type={inputTypes.TEXT_AREA}
name="customFormatJson"
value={json}
onChange={this.onChange}
placeholder={'{\n "name": "Custom Format"\n}'}
errors={parseError ? [parseError] : []}
/>
</FormGroup>
</Form>
}
</div>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
onPress={this.onImportPress}
isSpinning={isSpinning}
error={parseError}
>
Import
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
}
ImportCustomFormatModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
specificationsPopulated: PropTypes.bool.isRequired,
onImportPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ImportCustomFormatModalContent;

View File

@ -0,0 +1,145 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { clearCustomFormatSpecificationPending, deleteAllCustomFormatSpecification, fetchCustomFormatSpecificationSchema, saveCustomFormatSpecification, selectCustomFormatSpecificationSchema, setCustomFormatSpecificationFieldValue, setCustomFormatSpecificationValue, setCustomFormatValue } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import ImportCustomFormatModalContent from './ImportCustomFormatModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('customFormats'),
(state) => state.settings.customFormatSpecifications,
(advancedSettings, customFormat, specifications) => {
return {
advancedSettings,
...customFormat,
specificationsPopulated: specifications.isPopulated,
specificationSchema: specifications.schema
};
}
);
}
const mapDispatchToProps = {
deleteAllCustomFormatSpecification,
clearCustomFormatSpecificationPending,
clearPendingChanges,
saveCustomFormatSpecification,
selectCustomFormatSpecificationSchema,
setCustomFormatSpecificationFieldValue,
setCustomFormatSpecificationValue,
setCustomFormatValue,
fetchCustomFormatSpecificationSchema
};
class ImportCustomFormatModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchCustomFormatSpecificationSchema();
}
//
// Listeners
clearPending = () => {
this.props.clearPendingChanges({ section: 'settings.customFormats' });
this.props.clearCustomFormatSpecificationPending();
this.props.deleteAllCustomFormatSpecification();
};
onImportPress = (payload) => {
this.clearPending();
try {
const cf = JSON.parse(payload);
this.parseCf(cf);
} catch (err) {
this.clearPending();
return {
message: err.message,
detailedMessage: err.stack
};
}
return null;
};
parseCf = (cf) => {
for (const [key, value] of Object.entries(cf)) {
if (key === 'specifications') {
for (const spec of value) {
this.parseSpecification(spec);
}
} else if (key !== 'id') {
this.props.setCustomFormatValue({ name: key, value });
}
}
};
parseSpecification = (spec) => {
const selectedImplementation = _.find(this.props.specificationSchema, { implementation: spec.implementation });
if (!selectedImplementation) {
throw new Error(`Unknown Custom Format condition '${spec.implementation}'`);
}
this.props.selectCustomFormatSpecificationSchema({ implementation: spec.implementation });
for (const [key, value] of Object.entries(spec)) {
if (key === 'fields') {
this.parseFields(value, selectedImplementation);
} else if (key !== 'id') {
this.props.setCustomFormatSpecificationValue({ name: key, value });
}
}
this.props.saveCustomFormatSpecification();
};
parseFields = (fields, schema) => {
for (const [key, value] of Object.entries(fields)) {
const field = _.find(schema.fields, { name: key });
if (!field) {
throw new Error(`Unknown option '${key}' for condition '${schema.implementationName}'`);
}
this.props.setCustomFormatSpecificationFieldValue({ name: key, value });
}
};
//
// Render
render() {
return (
<ImportCustomFormatModalContent
{...this.props}
onImportPress={this.onImportPress}
/>
);
}
}
ImportCustomFormatModalContentConnector.propTypes = {
specificationSchema: PropTypes.arrayOf(PropTypes.object).isRequired,
clearPendingChanges: PropTypes.func.isRequired,
deleteAllCustomFormatSpecification: PropTypes.func.isRequired,
clearCustomFormatSpecificationPending: PropTypes.func.isRequired,
saveCustomFormatSpecification: PropTypes.func.isRequired,
fetchCustomFormatSpecificationSchema: PropTypes.func.isRequired,
selectCustomFormatSpecificationSchema: PropTypes.func.isRequired,
setCustomFormatSpecificationValue: PropTypes.func.isRequired,
setCustomFormatSpecificationFieldValue: PropTypes.func.isRequired,
setCustomFormatValue: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportCustomFormatModalContentConnector);

View File

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

View File

@ -0,0 +1,110 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem';
import styles from './AddSpecificationItem.css';
class AddSpecificationItem extends Component {
//
// Listeners
onSpecificationSelect = () => {
const {
implementation
} = this.props;
this.props.onSpecificationSelect({ implementation });
};
//
// Render
render() {
const {
implementation,
implementationName,
infoLink,
presets,
onSpecificationSelect
} = this.props;
const hasPresets = !!presets && !!presets.length;
return (
<div
className={styles.specification}
>
<Link
className={styles.underlay}
onPress={this.onSpecificationSelect}
/>
<div className={styles.overlay}>
<div className={styles.name}>
{implementationName}
</div>
<div className={styles.actions}>
{
hasPresets &&
<span>
<Button
size={sizes.SMALL}
onPress={this.onSpecificationSelect}
>
Custom
</Button>
<Menu className={styles.presetsMenu}>
<Button
className={styles.presetsMenuButton}
size={sizes.SMALL}
>
Presets
</Button>
<MenuContent>
{
presets.map((preset, index) => {
return (
<AddSpecificationPresetMenuItem
key={index}
name={preset.name}
implementation={implementation}
onPress={onSpecificationSelect}
/>
);
})
}
</MenuContent>
</Menu>
</span>
}
<Button
to={infoLink}
size={sizes.SMALL}
>
More Info
</Button>
</div>
</div>
</div>
);
}
}
AddSpecificationItem.propTypes = {
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
infoLink: PropTypes.string.isRequired,
presets: PropTypes.arrayOf(PropTypes.object),
onSpecificationSelect: PropTypes.func.isRequired
};
export default AddSpecificationItem;

View File

@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddSpecificationModalContentConnector from './AddSpecificationModalContentConnector';
function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddSpecificationModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddSpecificationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddSpecificationModal;

View File

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

View File

@ -0,0 +1,101 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import AddSpecificationItem from './AddSpecificationItem';
import styles from './AddSpecificationModalContent.css';
class AddSpecificationModalContent extends Component {
//
// Render
render() {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema,
onSpecificationSelect,
onModalClose
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add Condition
</ModalHeader>
<ModalBody>
{
isSchemaFetching &&
<LoadingIndicator />
}
{
!isSchemaFetching && !!schemaError &&
<div>
{'Unable to add a new condition, please try again.'}
</div>
}
{
isSchemaPopulated && !schemaError &&
<div>
<Alert kind={kinds.INFO}>
<div>
{'Sonarr supports custom conditions against the release properties below.'}
</div>
<div>
{'Visit the wiki for more details: '}
<Link to="https://wiki.servarr.com/sonarr/settings#custom-formats-2">{'Wiki'}</Link>
</div>
</Alert>
<div className={styles.specifications}>
{
schema.map((specification) => {
return (
<AddSpecificationItem
key={specification.implementation}
{...specification}
onSpecificationSelect={onSpecificationSelect}
/>
);
})
}
</div>
</div>
}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
AddSpecificationModalContent.propTypes = {
isSchemaFetching: PropTypes.bool.isRequired,
isSchemaPopulated: PropTypes.bool.isRequired,
schemaError: PropTypes.object,
schema: PropTypes.arrayOf(PropTypes.object).isRequired,
onSpecificationSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddSpecificationModalContent;

View File

@ -0,0 +1,70 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchCustomFormatSpecificationSchema, selectCustomFormatSpecificationSchema } from 'Store/Actions/settingsActions';
import AddSpecificationModalContent from './AddSpecificationModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.customFormatSpecifications,
(specifications) => {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema
} = specifications;
return {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema
};
}
);
}
const mapDispatchToProps = {
fetchCustomFormatSpecificationSchema,
selectCustomFormatSpecificationSchema
};
class AddSpecificationModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchCustomFormatSpecificationSchema();
}
//
// Listeners
onSpecificationSelect = ({ implementation, name }) => {
this.props.selectCustomFormatSpecificationSchema({ implementation, presetName: name });
this.props.onModalClose({ specificationSelected: true });
};
//
// Render
render() {
return (
<AddSpecificationModalContent
{...this.props}
onSpecificationSelect={this.onSpecificationSelect}
/>
);
}
}
AddSpecificationModalContentConnector.propTypes = {
fetchCustomFormatSpecificationSchema: PropTypes.func.isRequired,
selectCustomFormatSpecificationSchema: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddSpecificationModalContentConnector);

View File

@ -0,0 +1,49 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MenuItem from 'Components/Menu/MenuItem';
class AddSpecificationPresetMenuItem extends Component {
//
// Listeners
onPress = () => {
const {
name,
implementation
} = this.props;
this.props.onPress({
name,
implementation
});
};
//
// Render
render() {
const {
name,
implementation,
...otherProps
} = this.props;
return (
<MenuItem
{...otherProps}
onPress={this.onPress}
>
{name}
</MenuItem>
);
}
}
AddSpecificationPresetMenuItem.propTypes = {
name: PropTypes.string.isRequired,
implementation: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
export default AddSpecificationPresetMenuItem;

View File

@ -0,0 +1,27 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EditSpecificationModalContentConnector from './EditSpecificationModalContentConnector';
function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditSpecificationModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditSpecificationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditSpecificationModal;

View File

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditSpecificationModal from './EditSpecificationModal';
function createMapDispatchToProps(dispatch, props) {
const section = 'settings.customFormatSpecifications';
return {
dispatchClearPendingChanges() {
dispatch(clearPendingChanges({ section }));
}
};
}
class EditSpecificationModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.dispatchClearPendingChanges();
this.props.onModalClose();
};
//
// Render
render() {
const {
dispatchClearPendingChanges,
...otherProps
} = this.props;
return (
<EditSpecificationModal
{...otherProps}
onModalClose={this.onModalClose}
/>
);
}
}
EditSpecificationModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(EditSpecificationModalConnector);

View File

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

View File

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

View File

@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearCustomFormatSpecificationPending, saveCustomFormatSpecification, setCustomFormatSpecificationFieldValue, setCustomFormatSpecificationValue } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditSpecificationModalContent from './EditSpecificationModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('customFormatSpecifications'),
(advancedSettings, specification) => {
return {
advancedSettings,
...specification
};
}
);
}
const mapDispatchToProps = {
setCustomFormatSpecificationValue,
setCustomFormatSpecificationFieldValue,
saveCustomFormatSpecification,
clearCustomFormatSpecificationPending
};
class EditSpecificationModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setCustomFormatSpecificationValue({ name, value });
};
onFieldChange = ({ name, value }) => {
this.props.setCustomFormatSpecificationFieldValue({ name, value });
};
onCancelPress = () => {
this.props.clearCustomFormatSpecificationPending();
this.props.onModalClose();
};
onSavePress = () => {
this.props.saveCustomFormatSpecification({ id: this.props.id });
this.props.onModalClose();
};
//
// Render
render() {
return (
<EditSpecificationModalContent
{...this.props}
onCancelPress={this.onCancelPress}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
EditSpecificationModalContentConnector.propTypes = {
id: PropTypes.number,
item: PropTypes.object.isRequired,
setCustomFormatSpecificationValue: PropTypes.func.isRequired,
setCustomFormatSpecificationFieldValue: PropTypes.func.isRequired,
clearCustomFormatSpecificationPending: PropTypes.func.isRequired,
saveCustomFormatSpecification: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditSpecificationModalContentConnector);

View File

@ -0,0 +1,38 @@
.customFormat {
composes: card from '~Components/Card.css';
width: 300px;
}
.nameContainer {
display: flex;
justify-content: space-between;
}
.name {
@add-mixin truncate;
margin-bottom: 20px;
font-weight: 300;
font-size: 24px;
}
.cloneButton {
composes: button from '~Components/Link/IconButton.css';
height: 36px;
}
.labels {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
pointer-events: all;
}
.tooltipLabel {
composes: label from '~Components/Label.css';
margin: 0;
border: none;
}

View File

@ -0,0 +1,139 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, kinds } from 'Helpers/Props';
import EditSpecificationModalConnector from './EditSpecificationModal';
import styles from './Specification.css';
class Specification extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditSpecificationModalOpen: false,
isDeleteSpecificationModalOpen: false
};
}
//
// Listeners
onEditSpecificationPress = () => {
this.setState({ isEditSpecificationModalOpen: true });
};
onEditSpecificationModalClose = () => {
this.setState({ isEditSpecificationModalOpen: false });
};
onDeleteSpecificationPress = () => {
this.setState({
isEditSpecificationModalOpen: false,
isDeleteSpecificationModalOpen: true
});
};
onDeleteSpecificationModalClose = () => {
this.setState({ isDeleteSpecificationModalOpen: false });
};
onCloneSpecificationPress = () => {
this.props.onCloneSpecificationPress(this.props.id);
};
onConfirmDeleteSpecification = () => {
this.props.onConfirmDeleteSpecification(this.props.id);
};
//
// Lifecycle
render() {
const {
id,
implementationName,
name,
required,
negate
} = this.props;
return (
<Card
className={styles.customFormat}
overlayContent={true}
onPress={this.onEditSpecificationPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<IconButton
className={styles.cloneButton}
title="Clone"
name={icons.CLONE}
onPress={this.onCloneSpecificationPress}
/>
</div>
<div className={styles.labels}>
<Label kind={kinds.DEFAULT}>
{implementationName}
</Label>
{
negate &&
<Label kind={kinds.DANGER}>
Negated
</Label>
}
{
required &&
<Label kind={kinds.SUCCESS}>
Required
</Label>
}
</div>
<EditSpecificationModalConnector
id={id}
isOpen={this.state.isEditSpecificationModalOpen}
onModalClose={this.onEditSpecificationModalClose}
onDeleteSpecificationPress={this.onDeleteSpecificationPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteSpecificationModalOpen}
kind={kinds.DANGER}
title="Delete Format"
message={`Are you sure you want to delete format tag ${name} ?`}
confirmLabel="Delete"
onConfirm={this.onConfirmDeleteSpecification}
onCancel={this.onDeleteSpecificationModalClose}
/>
</Card>
);
}
}
Specification.propTypes = {
id: PropTypes.number.isRequired,
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
negate: PropTypes.bool.isRequired,
required: PropTypes.bool.isRequired,
fields: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteSpecification: PropTypes.func.isRequired,
onCloneSpecificationPress: PropTypes.func.isRequired
};
export default Specification;

View File

@ -261,11 +261,11 @@ class MediaManagement extends Component {
name="downloadPropersAndRepacks" name="downloadPropersAndRepacks"
helpTexts={[ helpTexts={[
'Whether or not to automatically upgrade to Propers/Repacks', 'Whether or not to automatically upgrade to Propers/Repacks',
'Use \'Do not Prefer\' to sort by preferred word score over propers/repacks' 'Use \'Do not Prefer\' to sort by custom format score over propers/repacks'
]} ]}
helpTextWarning={ helpTextWarning={
settings.downloadPropersAndRepacks.value === 'doNotPrefer' ? settings.downloadPropersAndRepacks.value === 'doNotPrefer' ?
'Use preferred words for automatic upgrades to propers/repacks' : 'Use custom formats for automatic upgrades to propers/repacks' :
undefined undefined
} }
values={downloadPropersAndRepacksOptions} values={downloadPropersAndRepacksOptions}

View File

@ -107,7 +107,7 @@ const mediaInfoTokens = [
const otherTokens = [ const otherTokens = [
{ token: '{Release Group}', example: 'Rls Grp' }, { token: '{Release Group}', example: 'Rls Grp' },
{ token: '{Preferred Words}', example: 'iNTERNAL' } { token: '{Custom Formats}', example: 'iNTERNAL' }
]; ];
const originalTokens = [ const originalTokens = [

View File

@ -3,7 +3,8 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.formGroupWrapper { .formGroupWrapper,
.formatItemLarge {
flex: 0 0 calc($formGroupSmallWidth - 100px); flex: 0 0 calc($formGroupSmallWidth - 100px);
} }
@ -11,8 +12,20 @@
margin-right: auto; margin-right: auto;
} }
@media only screen and (max-width: $breakpointLarge) { .formatItemSmall {
display: none;
}
@media only screen and (max-width: calc($breakpointLarge + 100px)) {
.formGroupsContainer { .formGroupsContainer {
display: block; display: block;
} }
.formatItemSmall {
display: block;
}
.formatItemLarge {
display: none;
}
} }

View File

@ -14,11 +14,23 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props'; import { inputTypes, kinds, sizes } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
import QualityProfileFormatItems from './QualityProfileFormatItems';
import QualityProfileItems from './QualityProfileItems'; import QualityProfileItems from './QualityProfileItems';
import styles from './EditQualityProfileModalContent.css'; import styles from './EditQualityProfileModalContent.css';
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding); const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
function getCustomFormatRender(formatItems, otherProps) {
return (
<QualityProfileFormatItems
profileFormatItems={formatItems.value}
errors={formatItems.errors}
warnings={formatItems.warnings}
{...otherProps}
/>
);
}
class EditQualityProfileModalContent extends Component { class EditQualityProfileModalContent extends Component {
// //
@ -92,6 +104,7 @@ class EditQualityProfileModalContent extends Component {
isSaving, isSaving,
saveError, saveError,
qualities, qualities,
customFormats,
item, item,
isInUse, isInUse,
onInputChange, onInputChange,
@ -107,7 +120,10 @@ class EditQualityProfileModalContent extends Component {
name, name,
upgradeAllowed, upgradeAllowed,
cutoff, cutoff,
items minFormatScore,
cutoffFormatScore,
items,
formatItems
} = item; } = item;
return ( return (
@ -189,6 +205,44 @@ class EditQualityProfileModalContent extends Component {
/> />
</FormGroup> </FormGroup>
} }
{
formatItems.value.length > 0 &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Minimum Custom Format Score
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minFormatScore"
{...minFormatScore}
helpText="Minimum custom format score allowed to download"
onChange={onInputChange}
/>
</FormGroup>
}
{
upgradeAllowed.value && formatItems.value.length > 0 &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Upgrade Until Custom Format Score
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="cutoffFormatScore"
{...cutoffFormatScore}
helpText="Once this custom format score is reached Sonarr will no longer grab episode releases"
onChange={onInputChange}
/>
</FormGroup>
}
<div className={styles.formatItemLarge}>
{getCustomFormatRender(formatItems, ...otherProps)}
</div>
</div> </div>
<div className={styles.formGroupWrapper}> <div className={styles.formGroupWrapper}>
@ -200,6 +254,10 @@ class EditQualityProfileModalContent extends Component {
{...otherProps} {...otherProps}
/> />
</div> </div>
<div className={styles.formatItemSmall}>
{getCustomFormatRender(formatItems, otherProps)}
</div>
</div> </div>
</Form> </Form>
@ -215,7 +273,7 @@ class EditQualityProfileModalContent extends Component {
> >
<ModalFooter> <ModalFooter>
{ {
id && id ?
<div <div
className={styles.deleteButtonContainer} className={styles.deleteButtonContainer}
title={ title={
@ -231,7 +289,8 @@ class EditQualityProfileModalContent extends Component {
> >
Delete Delete
</Button> </Button>
</div> </div> :
null
} }
<Button <Button
@ -261,6 +320,7 @@ EditQualityProfileModalContent.propTypes = {
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object, saveError: PropTypes.object,
qualities: PropTypes.arrayOf(PropTypes.object).isRequired, qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
isInUse: PropTypes.bool.isRequired, isInUse: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,

View File

@ -61,14 +61,46 @@ function createQualitiesSelector() {
); );
} }
function createFormatsSelector() {
return createSelector(
createProviderSettingsSelector('qualityProfiles'),
(customFormat) => {
const items = customFormat.item.formatItems;
if (!items || !items.value) {
return [];
}
return _.reduceRight(items.value, (result, { id, name, format, score }) => {
if (id) {
result.push({
key: id,
value: name,
score
});
} else {
result.push({
key: format,
value: name,
score
});
}
return result;
}, []);
}
);
}
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createProviderSettingsSelector('qualityProfiles'), createProviderSettingsSelector('qualityProfiles'),
createQualitiesSelector(), createQualitiesSelector(),
createFormatsSelector(),
createProfileInUseSelector('qualityProfileId'), createProfileInUseSelector('qualityProfileId'),
(qualityProfile, qualities, isInUse) => { (qualityProfile, qualities, customFormats, isInUse) => {
return { return {
qualities, qualities,
customFormats,
...qualityProfile, ...qualityProfile,
isInUse isInUse
}; };
@ -178,6 +210,19 @@ class EditQualityProfileModalContentConnector extends Component {
this.ensureCutoff(qualityProfile); this.ensureCutoff(qualityProfile);
}; };
onQualityProfileFormatItemScoreChange = (id, score) => {
const qualityProfile = _.cloneDeep(this.props.item);
const formatItems = qualityProfile.formatItems.value;
const item = _.find(qualityProfile.formatItems.value, (i) => i.format === id);
item.score = score;
this.props.setQualityProfileValue({
name: 'formatItems',
value: formatItems
});
};
onItemGroupAllowedChange = (id, allowed) => { onItemGroupAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item); const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value; const items = qualityProfile.items.value;
@ -420,6 +465,7 @@ class EditQualityProfileModalContentConnector extends Component {
onItemGroupNameChange={this.onItemGroupNameChange} onItemGroupNameChange={this.onItemGroupNameChange}
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove} onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd} onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onQualityProfileFormatItemScoreChange={this.onQualityProfileFormatItemScoreChange}
onToggleEditGroupsMode={this.onToggleEditGroupsMode} onToggleEditGroupsMode={this.onToggleEditGroupsMode}
/> />
); );

View File

@ -0,0 +1,45 @@
.qualityProfileFormatItemContainer {
display: flex;
padding: $qualityProfileItemDragSourcePadding 0;
width: 100%;
}
.qualityProfileFormatItem {
display: flex;
align-items: stretch;
width: 100%;
border: 1px solid #aaa;
border-radius: 4px;
background: var(--inputBackgroundColor);
}
.formatNameContainer {
display: flex;
flex-grow: 1;
margin-bottom: 0;
margin-left: 14px;
width: 100%;
font-weight: normal;
line-height: $qualityProfileItemHeight;
cursor: text;
}
.formatName {
display: flex;
flex-grow: 1;
}
.scoreContainer {
display: flex;
flex-grow: 0;
}
.scoreInput {
composes: input from '~Components/Form/Input.css';
width: 100px;
height: 30px;
border: unset;
border-radius: unset;
background-color: unset;
}

View File

@ -0,0 +1,68 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import NumberInput from 'Components/Form/NumberInput';
import styles from './QualityProfileFormatItem.css';
class QualityProfileFormatItem extends Component {
//
// Listeners
onScoreChange = ({ value }) => {
const {
formatId
} = this.props;
this.props.onScoreChange(formatId, value);
};
//
// Render
render() {
const {
name,
score
} = this.props;
return (
<div
className={styles.qualityProfileFormatItemContainer}
>
<div
className={styles.qualityProfileFormatItem}
>
<label
className={styles.formatNameContainer}
>
<div className={styles.formatName}>
{name}
</div>
<NumberInput
containerClassName={styles.scoreContainer}
className={styles.scoreInput}
name={name}
value={score}
onChange={this.onScoreChange}
/>
</label>
</div>
</div>
);
}
}
QualityProfileFormatItem.propTypes = {
formatId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
onScoreChange: PropTypes.func
};
QualityProfileFormatItem.defaultProps = {
// To handle the case score is deleted during edit
score: 0
};
export default QualityProfileFormatItem;

View File

@ -0,0 +1,31 @@
.formats {
margin-top: 10px;
/* TODO: This should consider the number of languages in the list */
user-select: none;
}
.headerContainer {
display: flex;
font-weight: bold;
line-height: 35px;
}
.headerTitle {
display: flex;
flex-grow: 1;
}
.headerScore {
display: flex;
flex-grow: 0;
padding-left: 16px;
width: 100px;
}
.addCustomFormatMessage {
max-width: $formGroupExtraSmallWidth;
color: var(--helpTextColor);
text-align: center;
font-weight: 300;
font-size: 20px;
}

View File

@ -0,0 +1,159 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import Link from 'Components/Link/Link';
import { sizes } from 'Helpers/Props';
import QualityProfileFormatItem from './QualityProfileFormatItem';
import styles from './QualityProfileFormatItems.css';
function calcOrder(profileFormatItems) {
const items = profileFormatItems.reduce((acc, cur, index) => {
acc[cur.format] = index;
return acc;
}, {});
return [...profileFormatItems].sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.name > b.name ? 1 : -1;
}).map((x) => items[x.format]);
}
class QualityProfileFormatItems extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
order: calcOrder(this.props.profileFormatItems)
};
}
//
// Listeners
onScoreChange = (formatId, value) => {
const {
onQualityProfileFormatItemScoreChange
} = this.props;
onQualityProfileFormatItemScoreChange(formatId, value);
this.reorderItems();
};
reorderItems = _.debounce(() => this.setState({ order: calcOrder(this.props.profileFormatItems) }), 1000);
//
// Render
render() {
const {
profileFormatItems,
errors,
warnings
} = this.props;
const {
order
} = this.state;
if (profileFormatItems.length < 1) {
return (
<div className={styles.addCustomFormatMessage}>
{'Want more control over which downloads are preferred? Add a'}
<Link to='/settings/customformats'> Custom Format </Link>
</div>
);
}
return (
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Custom Formats
</FormLabel>
<div>
<FormInputHelpText
text="Sonarr scores each release using the sum of scores for matching custom formats. If a new release would improve the score, at the same or better quality, then Sonarr will grab it."
/>
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={false}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={false}
/>
);
})
}
<div className={styles.formats}>
<div className={styles.headerContainer}>
<div className={styles.headerTitle}>
Custom Format
</div>
<div className={styles.headerScore}>
Score
</div>
</div>
{
order.map((index) => {
const {
format,
name,
score
} = profileFormatItems[index];
return (
<QualityProfileFormatItem
key={format}
formatId={format}
name={name}
score={score}
onScoreChange={this.onScoreChange}
/>
);
})
}
</div>
</div>
</FormGroup>
);
}
}
QualityProfileFormatItems.propTypes = {
profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),
onQualityProfileFormatItemScoreChange: PropTypes.func
};
QualityProfileFormatItems.defaultProps = {
errors: [],
warnings: []
};
export default QualityProfileFormatItems;

View File

@ -33,8 +33,6 @@ function EditReleaseProfileModalContent(props) {
enabled, enabled,
required, required,
ignored, ignored,
preferred,
includePreferredWhenRenaming,
tags, tags,
indexerId indexerId
} = item; } = item;
@ -105,37 +103,6 @@ function EditReleaseProfileModalContent(props) {
/> />
</FormGroup> </FormGroup>
<FormGroup>
<FormLabel>Preferred</FormLabel>
<FormInputGroup
type={inputTypes.KEY_VALUE_LIST}
name="preferred"
helpTexts={[
'The release will be preferred based on the each term\'s score (case insensitive)',
'A positive score will be more preferred',
'A negative score will be less preferred'
]}
{...preferred}
keyPlaceholder="Term"
valuePlaceholder="Score"
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Include Preferred when Renaming</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePreferredWhenRenaming"
helpText={indexerId.value === 0 ? 'Include in {Preferred Words} renaming format' : 'Only supported when Indexer is set to (All)'}
{...includePreferredWhenRenaming}
onChange={onInputChange}
isDisabled={indexerId.value !== 0}
/>
</FormGroup>
<FormGroup> <FormGroup>
<FormLabel>Indexer</FormLabel> <FormLabel>Indexer</FormLabel>
@ -143,7 +110,7 @@ function EditReleaseProfileModalContent(props) {
type={inputTypes.INDEXER_SELECT} type={inputTypes.INDEXER_SELECT}
name="indexerId" name="indexerId"
helpText="Specify what indexer the profile applies to" helpText="Specify what indexer the profile applies to"
helpTextWarning="Using a specific indexer with preferred words can lead to duplicate releases being grabbed" helpTextWarning="Using a specific indexer with release profiles can lead to duplicate releases being grabbed"
{...indexerId} {...indexerId}
includeAny={true} includeAny={true}
onChange={onInputChange} onChange={onInputChange}

View File

@ -11,7 +11,6 @@ const newReleaseProfile = {
enabled: true, enabled: true,
required: [], required: [],
ignored: [], ignored: [],
preferred: [],
includePreferredWhenRenaming: false, includePreferredWhenRenaming: false,
tags: [], tags: [],
indexerId: 0 indexerId: 0

View File

@ -60,7 +60,6 @@ class ReleaseProfile extends Component {
enabled, enabled,
required, required,
ignored, ignored,
preferred,
tags, tags,
indexerId, indexerId,
tagList, tagList,
@ -112,28 +111,6 @@ class ReleaseProfile extends Component {
} }
</div> </div>
<div>
{
preferred.map((item) => {
const isPreferred = item.value >= 0;
return (
<Label
className={styles.label}
key={item.key}
kind={isPreferred ? kinds.DEFAULT : kinds.WARNING}
>
<MiddleTruncate
text={`${item.key} ${isPreferred ? '+' : ''}${item.value}`}
start={10}
end={14}
/>
</Label>
);
})
}
</div>
<div> <div>
{ {
ignored.map((item) => { ignored.map((item) => {
@ -212,7 +189,6 @@ ReleaseProfile.propTypes = {
enabled: PropTypes.bool.isRequired, enabled: PropTypes.bool.isRequired,
required: PropTypes.arrayOf(PropTypes.string).isRequired, required: PropTypes.arrayOf(PropTypes.string).isRequired,
ignored: PropTypes.arrayOf(PropTypes.string).isRequired, ignored: PropTypes.arrayOf(PropTypes.string).isRequired,
preferred: PropTypes.arrayOf(PropTypes.object).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerId: PropTypes.number.isRequired, indexerId: PropTypes.number.isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired, tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
@ -224,7 +200,6 @@ ReleaseProfile.defaultProps = {
enabled: true, enabled: true,
required: [], required: [],
ignored: [], ignored: [],
preferred: [],
indexerId: 0 indexerId: 0
}; };

View File

@ -46,6 +46,17 @@ function Settings() {
Quality sizes and naming Quality sizes and naming
</div> </div>
<Link
className={styles.link}
to="/settings/customformats"
>
Custom Formats
</Link>
<div className={styles.summary}>
Custom Formats and Settings
</div>
<Link <Link
className={styles.link} className={styles.link}
to="/settings/indexers" to="/settings/indexers"

View File

@ -0,0 +1,193 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import getNextId from 'Utilities/State/getNextId';
import getProviderState from 'Utilities/State/getProviderState';
import getSectionState from 'Utilities/State/getSectionState';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import updateSectionState from 'Utilities/State/updateSectionState';
import { removeItem, set, update, updateItem } from '../baseActions';
//
// Variables
const section = 'settings.customFormatSpecifications';
//
// Actions Types
export const FETCH_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/fetchCustomFormatSpecifications';
export const FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA = 'settings/customFormatSpecifications/fetchCustomFormatSpecificationSchema';
export const SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA = 'settings/customFormatSpecifications/selectCustomFormatSpecificationSchema';
export const SET_CUSTOM_FORMAT_SPECIFICATION_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationValue';
export const SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationFieldValue';
export const SAVE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/saveCustomFormatSpecification';
export const DELETE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteCustomFormatSpecification';
export const DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteAllCustomFormatSpecification';
export const CLONE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/cloneCustomFormatSpecification';
export const CLEAR_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/clearCustomFormatSpecifications';
export const CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING = 'settings/customFormatSpecifications/clearCustomFormatSpecificationPending';
//
// Action Creators
export const fetchCustomFormatSpecifications = createThunk(FETCH_CUSTOM_FORMAT_SPECIFICATIONS);
export const fetchCustomFormatSpecificationSchema = createThunk(FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA);
export const selectCustomFormatSpecificationSchema = createAction(SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA);
export const saveCustomFormatSpecification = createThunk(SAVE_CUSTOM_FORMAT_SPECIFICATION);
export const deleteCustomFormatSpecification = createThunk(DELETE_CUSTOM_FORMAT_SPECIFICATION);
export const deleteAllCustomFormatSpecification = createThunk(DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION);
export const setCustomFormatSpecificationValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setCustomFormatSpecificationFieldValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE, (payload) => {
return {
section,
...payload
};
});
export const cloneCustomFormatSpecification = createAction(CLONE_CUSTOM_FORMAT_SPECIFICATION);
export const clearCustomFormatSpecification = createAction(CLEAR_CUSTOM_FORMAT_SPECIFICATIONS);
export const clearCustomFormatSpecificationPending = createThunk(CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING);
//
// Details
export default {
//
// State
defaultState: {
isPopulated: false,
error: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: [],
selectedSchema: {},
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_CUSTOM_FORMAT_SPECIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/customformat/schema'),
[FETCH_CUSTOM_FORMAT_SPECIFICATIONS]: (getState, payload, dispatch) => {
let tags = [];
if (payload.id) {
const cfState = getSectionState(getState(), 'settings.customFormats', true);
const cf = cfState.items[cfState.itemMap[payload.id]];
tags = cf.specifications.map((tag, i) => {
return {
id: i + 1,
...tag
};
});
}
dispatch(batchActions([
update({ section, data: tags }),
set({
section,
isPopulated: true
})
]));
},
[SAVE_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
const {
id,
...otherPayload
} = payload;
const saveData = getProviderState({ id, ...otherPayload }, getState, section, false);
// we have to set id since not actually posting to server yet
if (!saveData.id) {
saveData.id = getNextId(getState().settings.customFormatSpecifications.items);
}
dispatch(batchActions([
updateItem({ section, ...saveData }),
set({
section,
pendingChanges: {}
})
]));
},
[DELETE_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
const id = payload.id;
return dispatch(removeItem({ section, id }));
},
[DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => {
return dispatch(set({
section,
items: []
}));
},
[CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING]: (getState, payload, dispatch) => {
return dispatch(set({
section,
pendingChanges: {}
}));
}
},
//
// Reducers
reducers: {
[SET_CUSTOM_FORMAT_SPECIFICATION_VALUE]: createSetSettingValueReducer(section),
[SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
[SELECT_CUSTOM_FORMAT_SPECIFICATION_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
return selectedSchema;
});
},
[CLONE_CUSTOM_FORMAT_SPECIFICATION]: function(state, { payload }) {
const id = payload.id;
const newState = getSectionState(state, section);
const items = newState.items;
const item = items.find((i) => i.id === id);
const newId = getNextId(newState.items);
const newItem = {
...item,
id: newId,
name: `${item.name} - Copy`
};
newState.items = [...items, newItem];
newState.itemMap[newId] = newState.items.length - 1;
return updateSectionState(state, section, newState);
},
[CLEAR_CUSTOM_FORMAT_SPECIFICATIONS]: createClearReducer(section, {
isPopulated: false,
error: null,
items: []
})
}
};

View File

@ -0,0 +1,108 @@
import { createAction } from 'redux-actions';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import { set } from '../baseActions';
//
// Variables
const section = 'settings.customFormats';
//
// Actions Types
export const FETCH_CUSTOM_FORMATS = 'settings/customFormats/fetchCustomFormats';
export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat';
export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat';
export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue';
export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
//
// Action Creators
export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS);
export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT);
export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT);
export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => {
return {
section,
...payload
};
});
export const cloneCustomFormat = createAction(CLONE_CUSTOM_FORMAT);
//
// Details
export default {
//
// State
defaultState: {
isSchemaFetching: false,
isSchemaPopulated: false,
isFetching: false,
isPopulated: false,
schema: {
includeCustomFormatWhenRenaming: false
},
error: null,
isDeleting: false,
deleteError: null,
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_CUSTOM_FORMATS]: createFetchHandler(section, '/customformat'),
[DELETE_CUSTOM_FORMAT]: createRemoveItemHandler(section, '/customformat'),
[SAVE_CUSTOM_FORMAT]: (getState, payload, dispatch) => {
// move the format tags in as a pending change
const state = getState();
const pendingChanges = state.settings.customFormats.pendingChanges;
pendingChanges.specifications = state.settings.customFormatSpecifications.items;
dispatch(set({
section,
pendingChanges
}));
createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch);
}
},
//
// Reducers
reducers: {
[SET_CUSTOM_FORMAT_VALUE]: createSetSettingValueReducer(section),
[CLONE_CUSTOM_FORMAT]: function(state, { payload }) {
const id = payload.id;
const newState = getSectionState(state, section);
const item = newState.items.find((i) => i.id === id);
const pendingChanges = { ...item, id: 0 };
delete pendingChanges.id;
pendingChanges.name = `${pendingChanges.name} - Copy`;
newState.pendingChanges = pendingChanges;
return updateSectionState(state, section, newState);
}
}
};

View File

@ -52,6 +52,12 @@ export const defaultState = {
label: 'Quality', label: 'Quality',
isVisible: true isVisible: true
}, },
{
name: 'customFormats',
label: 'Formats',
isSortable: false,
isVisible: true
},
{ {
name: 'date', name: 'date',
label: 'Date', label: 'Date',

View File

@ -99,6 +99,11 @@ export const defaultState = {
label: 'Release Group', label: 'Release Group',
isVisible: false isVisible: false
}, },
{
name: 'customFormats',
label: 'Formats',
isVisible: false
},
{ {
name: 'status', name: 'status',
label: 'Status', label: 'Status',

View File

@ -61,6 +61,12 @@ export const defaultState = {
label: 'Quality', label: 'Quality',
isVisible: true isVisible: true
}, },
{
name: 'customFormats',
label: 'Formats',
isSortable: false,
isVisible: true
},
{ {
name: 'date', name: 'date',
label: 'Date', label: 'Date',
@ -88,11 +94,11 @@ export const defaultState = {
isVisible: false isVisible: false
}, },
{ {
name: 'preferredWordScore', name: 'customFormatScore',
columnLabel: 'Preferred Word Score', columnLabel: 'Custom Format Score',
label: React.createElement(Icon, { label: React.createElement(Icon, {
name: icons.SCORE, name: icons.SCORE,
title: 'Preferred word score' title: 'Custom format score'
}), }),
isVisible: false isVisible: false
}, },

View File

@ -98,6 +98,12 @@ export const defaultState = {
isSortable: true, isSortable: true,
isVisible: true isVisible: true
}, },
{
name: 'customFormats',
label: 'Formats',
isSortable: false,
isVisible: true
},
{ {
name: 'protocol', name: 'protocol',
label: 'Protocol', label: 'Protocol',

View File

@ -1,6 +1,8 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { handleThunks } from 'Store/thunks'; import { handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
import customFormats from './Settings/customFormats';
import customFormatSpecifications from './Settings/customFormatSpecifications';
import delayProfiles from './Settings/delayProfiles'; import delayProfiles from './Settings/delayProfiles';
import downloadClientOptions from './Settings/downloadClientOptions'; import downloadClientOptions from './Settings/downloadClientOptions';
import downloadClients from './Settings/downloadClients'; import downloadClients from './Settings/downloadClients';
@ -21,6 +23,8 @@ import releaseProfiles from './Settings/releaseProfiles';
import remotePathMappings from './Settings/remotePathMappings'; import remotePathMappings from './Settings/remotePathMappings';
import ui from './Settings/ui'; import ui from './Settings/ui';
export * from './Settings/customFormatSpecifications.js';
export * from './Settings/customFormats';
export * from './Settings/delayProfiles'; export * from './Settings/delayProfiles';
export * from './Settings/downloadClients'; export * from './Settings/downloadClients';
export * from './Settings/downloadClientOptions'; export * from './Settings/downloadClientOptions';
@ -52,6 +56,8 @@ export const section = 'settings';
export const defaultState = { export const defaultState = {
advancedSettings: false, advancedSettings: false,
customFormatSpecifications: customFormatSpecifications.defaultState,
customFormats: customFormats.defaultState,
delayProfiles: delayProfiles.defaultState, delayProfiles: delayProfiles.defaultState,
downloadClients: downloadClients.defaultState, downloadClients: downloadClients.defaultState,
downloadClientOptions: downloadClientOptions.defaultState, downloadClientOptions: downloadClientOptions.defaultState,
@ -91,6 +97,8 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
// Action Handlers // Action Handlers
export const actionHandlers = handleThunks({ export const actionHandlers = handleThunks({
...customFormatSpecifications.actionHandlers,
...customFormats.actionHandlers,
...delayProfiles.actionHandlers, ...delayProfiles.actionHandlers,
...downloadClients.actionHandlers, ...downloadClients.actionHandlers,
...downloadClientOptions.actionHandlers, ...downloadClientOptions.actionHandlers,
@ -121,6 +129,8 @@ export const reducers = createHandleActions({
return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
}, },
...customFormatSpecifications.reducers,
...customFormats.reducers,
...delayProfiles.reducers, ...delayProfiles.reducers,
...downloadClients.reducers, ...downloadClients.reducers,
...downloadClientOptions.reducers, ...downloadClientOptions.reducers,

View File

@ -0,0 +1,5 @@
function getNextId(items) {
return items.reduce((id, x) => Math.max(id, x.id), 1) + 1;
}
export default getNextId;

View File

@ -1,7 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import getSectionState from 'Utilities/State/getSectionState'; import getSectionState from 'Utilities/State/getSectionState';
function getProviderState(payload, getState, section) { function getProviderState(payload, getState, section, keyValueOnly=true) {
const { const {
id, id,
...otherPayload ...otherPayload
@ -23,10 +23,17 @@ function getProviderState(payload, getState, section) {
field.value; field.value;
// Only send the name and value to the server // Only send the name and value to the server
result.push({ if (keyValueOnly) {
name, result.push({
value name,
}); value
});
} else {
result.push({
...field,
value
});
}
return result; return result;
}, []); }, []);

View File

@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.CustomFormats
{
[TestFixture]
public class CustomFormatsFixture : CoreTest
{
private static List<CustomFormat> _customFormats { get; set; }
public static void GivenCustomFormats(params CustomFormat[] formats)
{
_customFormats = formats.ToList();
}
public static List<ProfileFormatItem> GetSampleFormatItems(params string[] allowed)
{
var allowedItems = _customFormats.Where(x => allowed.Contains(x.Name)).Select((f, index) => new ProfileFormatItem { Format = f, Score = (int)Math.Pow(2, index) }).ToList();
var disallowedItems = _customFormats.Where(x => !allowed.Contains(x.Name)).Select(f => new ProfileFormatItem { Format = f, Score = -1 * (int)Math.Pow(2, allowedItems.Count) });
return disallowedItems.Concat(allowedItems).ToList();
}
public static List<ProfileFormatItem> GetDefaultFormatItems()
{
return new List<ProfileFormatItem>();
}
}
}

View File

@ -0,0 +1,535 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class add_custom_formatsFixture : MigrationTest<add_custom_formats>
{
[Test]
public void should_add_cf_from_named_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat171>("SELECT Id, Name, IncludeCustomFormatWhenRenaming, Specifications FROM CustomFormats");
customFormats.Should().HaveCount(1);
customFormats.First().Name.Should().Be("Profile_1");
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
customFormats.First().Specifications.Should().HaveCount(1);
}
[Test]
public void should_not_migrate_if_bad_regex_in_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Preferred = new[]
{
new
{
Key = "[somestring[",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat171>("SELECT Id, Name, IncludeCustomFormatWhenRenaming, Specifications FROM CustomFormats");
customFormats.Should().HaveCount(0);
}
[Test]
public void should_set_cf_naming_token_if_set_in_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat171>("SELECT Id, Name, IncludeCustomFormatWhenRenaming, Specifications FROM CustomFormats");
customFormats.Should().HaveCount(1);
customFormats.First().Name.Should().Be("Profile_1");
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeTrue();
customFormats.First().Specifications.Should().HaveCount(1);
}
[Test]
public void should_not_remove_release_profile_if_ignored_or_required()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = new[]
{
"some",
"words"
}.ToJson(),
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var releaseProfiles = db.Query<ReleaseProfile171>("SELECT Id, Name FROM ReleaseProfiles");
releaseProfiles.Should().HaveCount(1);
releaseProfiles.First().Name.Should().Be("Profile");
}
[Test]
public void should_remove_release_profile_if_no_ignored_or_required()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var releaseProfiles = db.Query<ReleaseProfile171>("SELECT Id, Name FROM ReleaseProfiles");
releaseProfiles.Should().HaveCount(0);
}
[Test]
public void should_add_cf_from_unnamed_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat171>("SELECT Id, Name, IncludeCustomFormatWhenRenaming, Specifications FROM CustomFormats");
customFormats.Should().HaveCount(1);
customFormats.First().Name.Should().Be("Unnamed_1");
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
customFormats.First().Specifications.Should().HaveCount(1);
}
[Test]
public void should_add_cfs_from_multiple_unnamed_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x265",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat171>("SELECT Id, Name, IncludeCustomFormatWhenRenaming, Specifications FROM CustomFormats");
customFormats.Should().HaveCount(2);
customFormats.First().Name.Should().Be("Unnamed_1");
customFormats.Last().Name.Should().Be("Unnamed_2");
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
customFormats.First().Specifications.Should().HaveCount(1);
}
[Test]
public void should_add_cfs_same_named_release_profiles()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Some - Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
},
new
{
Key = "x265",
Value = 3
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Some - Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
},
new
{
Key = "x265",
Value = 3
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Some - Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
},
new
{
Key = "x265",
Value = 3
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat171>("SELECT Id, Name, IncludeCustomFormatWhenRenaming, Specifications FROM CustomFormats");
customFormats.Should().HaveCount(6);
customFormats.First().Name.Should().Be("Some - Profile_1_0");
customFormats.Last().Name.Should().Be("Some - Profile_3_1");
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
customFormats.First().Specifications.Should().HaveCount(1);
}
[Test]
public void should_add_two_cfs_if_release_profile_has_multiple_terms()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
},
new
{
Key = "x265",
Value = 5
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat171>("SELECT Id, Name, IncludeCustomFormatWhenRenaming, Specifications FROM CustomFormats");
customFormats.Should().HaveCount(2);
customFormats.First().Name.Should().Be("Profile_1_0");
customFormats.Last().Name.Should().Be("Profile_1_1");
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
customFormats.First().Specifications.Should().HaveCount(1);
}
[Test]
public void should_set_scores_for_enabled_release_profiles()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
c.Insert.IntoTable("QualityProfiles").Row(new
{
Name = "SDTV",
Cutoff = 1,
Items = "[ { \"quality\": 1, \"allowed\": true } ]"
});
});
var customFormats = db.Query<QualityProfile171>("SELECT Id, Name, FormatItems FROM QualityProfiles");
customFormats.Should().HaveCount(1);
customFormats.First().FormatItems.Should().HaveCount(1);
customFormats.First().FormatItems.First().Score.Should().Be(2);
}
[Test]
public void should_set_zero_scores_for_disabled_release_profiles()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Name = "Profile",
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "[]",
Ignored = "[]",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = false,
IndexerId = 0
});
c.Insert.IntoTable("QualityProfiles").Row(new
{
Name = "SDTV",
Cutoff = 1,
Items = "[ { \"quality\": 1, \"allowed\": true } ]"
});
});
var customFormats = db.Query<QualityProfile171>("SELECT Id, Name, FormatItems FROM QualityProfiles");
customFormats.Should().HaveCount(1);
customFormats.First().FormatItems.Should().HaveCount(1);
customFormats.First().FormatItems.First().Score.Should().Be(0);
}
[Test]
public void should_migrate_naming_configs()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("NamingConfig").Row(new
{
MultiEpisodeStyle = false,
StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Preferred Words } {Quality Full}",
DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title} {Preferred.Words } {Quality Full}",
AnimeEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Preferred_Words} {Quality Full}",
});
});
var customFormats = db.Query<NamingConfig171>("SELECT StandardEpisodeFormat, DailyEpisodeFormat, AnimeEpisodeFormat FROM NamingConfig");
customFormats.Should().HaveCount(1);
customFormats.First().StandardEpisodeFormat.Should().Be("{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Custom Formats } {Quality Full}");
customFormats.First().DailyEpisodeFormat.Should().Be("{Series Title} - {Air-Date} - {Episode Title} {Custom.Formats } {Quality Full}");
customFormats.First().AnimeEpisodeFormat.Should().Be("{Series Title} - S{season:00}E{episode:00} - {Custom_Formats} {Quality Full}");
}
private class NamingConfig171
{
public string StandardEpisodeFormat { get; set; }
public string DailyEpisodeFormat { get; set; }
public string AnimeEpisodeFormat { get; set; }
}
private class ReleaseProfile171
{
public int Id { get; set; }
public string Name { get; set; }
}
private class QualityProfile171
{
public int Id { get; set; }
public string Name { get; set; }
public List<FormatItem171> FormatItems { get; set; }
}
private class FormatItem171
{
public int Format { get; set; }
public int Score { get; set; }
}
private class CustomFormat171
{
public int Id { get; set; }
public string Name { get; set; }
public bool IncludeCustomFormatWhenRenaming { get; set; }
public List<CustomFormatSpec171> Specifications { get; set; }
}
private class CustomFormatSpec171
{
public string Type { get; set; }
public CustomFormatReleaseTitleSpec171 Body { get; set; }
}
private class CustomFormatReleaseTitleSpec171
{
public int Order { get; set; }
public string ImplementationName { get; set; }
public string Name { get; set; }
public string Value { get; set; }
public bool Required { get; set; }
public bool Negate { get; set; }
}
}
}

View File

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.CustomFormats;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.DecisionEngineTests
{
[TestFixture]
public class CustomFormatAllowedByProfileSpecificationFixture : CoreTest<CustomFormatAllowedbyProfileSpecification>
{
private RemoteEpisode _remoteEpisode;
private CustomFormat _format1;
private CustomFormat _format2;
[SetUp]
public void Setup()
{
_format1 = new CustomFormat("Awesome Format");
_format1.Id = 1;
_format2 = new CustomFormat("Cool Format");
_format2.Id = 2;
var fakeSeries = Builder<Series>.CreateNew()
.With(c => c.QualityProfile = new QualityProfile
{
Cutoff = Quality.Bluray1080p.Id,
MinFormatScore = 1
})
.Build();
_remoteEpisode = new RemoteEpisode
{
Series = fakeSeries,
ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) },
};
CustomFormatsFixture.GivenCustomFormats(_format1, _format2);
}
[Test]
public void should_allow_if_format_score_greater_than_min()
{
_remoteEpisode.CustomFormats = new List<CustomFormat> { _format1 };
_remoteEpisode.Series.QualityProfile.Value.FormatItems = CustomFormatsFixture.GetSampleFormatItems(_format1.Name);
_remoteEpisode.CustomFormatScore = _remoteEpisode.Series.QualityProfile.Value.CalculateCustomFormatScore(_remoteEpisode.CustomFormats);
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue();
}
[Test]
public void should_deny_if_format_score_not_greater_than_min()
{
_remoteEpisode.CustomFormats = new List<CustomFormat> { _format2 };
_remoteEpisode.Series.QualityProfile.Value.FormatItems = CustomFormatsFixture.GetSampleFormatItems(_format1.Name);
_remoteEpisode.CustomFormatScore = _remoteEpisode.Series.QualityProfile.Value.CalculateCustomFormatScore(_remoteEpisode.CustomFormats);
Console.WriteLine(_remoteEpisode.CustomFormatScore);
Console.WriteLine(_remoteEpisode.Series.QualityProfile.Value.MinFormatScore);
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse();
}
[Test]
public void should_deny_if_format_score_not_greater_than_min_2()
{
_remoteEpisode.CustomFormats = new List<CustomFormat> { _format2, _format1 };
_remoteEpisode.Series.QualityProfile.Value.FormatItems = CustomFormatsFixture.GetSampleFormatItems(_format1.Name);
_remoteEpisode.CustomFormatScore = _remoteEpisode.Series.QualityProfile.Value.CalculateCustomFormatScore(_remoteEpisode.CustomFormats);
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse();
}
[Test]
public void should_allow_if_all_format_is_defined_in_profile()
{
_remoteEpisode.CustomFormats = new List<CustomFormat> { _format2, _format1 };
_remoteEpisode.Series.QualityProfile.Value.FormatItems = CustomFormatsFixture.GetSampleFormatItems(_format1.Name, _format2.Name);
_remoteEpisode.CustomFormatScore = _remoteEpisode.Series.QualityProfile.Value.CalculateCustomFormatScore(_remoteEpisode.CustomFormats);
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue();
}
[Test]
public void should_deny_if_no_format_was_parsed_and_min_score_positive()
{
_remoteEpisode.CustomFormats = new List<CustomFormat> { };
_remoteEpisode.Series.QualityProfile.Value.FormatItems = CustomFormatsFixture.GetSampleFormatItems(_format1.Name, _format2.Name);
_remoteEpisode.CustomFormatScore = _remoteEpisode.Series.QualityProfile.Value.CalculateCustomFormatScore(_remoteEpisode.CustomFormats);
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse();
}
[Test]
public void should_allow_if_no_format_was_parsed_min_score_is_zero()
{
_remoteEpisode.CustomFormats = new List<CustomFormat> { };
_remoteEpisode.Series.QualityProfile.Value.FormatItems = CustomFormatsFixture.GetSampleFormatItems(_format1.Name, _format2.Name);
_remoteEpisode.Series.QualityProfile.Value.MinFormatScore = 0;
_remoteEpisode.CustomFormatScore = _remoteEpisode.Series.QualityProfile.Value.CalculateCustomFormatScore(_remoteEpisode.CustomFormats);
Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue();
}
}
}

View File

@ -1,367 +1,385 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Languages; using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Profiles.Languages;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.CustomFormats;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Test.Languages; using NzbDrone.Core.Test.Languages;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.DecisionEngineTests namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
[TestFixture] [TestFixture]
public class CutoffSpecificationFixture : CoreTest<UpgradableSpecification> public class CutoffSpecificationFixture : CoreTest<CutoffSpecification>
{ {
private static readonly int NoPreferredWordScore = 0; private CustomFormat _customFormat;
private RemoteEpisode _remoteMovie;
[SetUp]
public void Setup()
{
Mocker.SetConstant<IUpgradableSpecification>(Mocker.Resolve<UpgradableSpecification>());
_remoteMovie = new RemoteEpisode()
{
Series = Builder<Series>.CreateNew().Build(),
Episodes = new List<Episode> { Builder<Episode>.CreateNew().Build() },
ParsedEpisodeInfo = Builder<ParsedEpisodeInfo>.CreateNew().With(x => x.Quality = null).Build()
};
GivenOldCustomFormats(new List<CustomFormat>());
}
private void GivenProfile(QualityProfile profile)
{
CustomFormatsFixture.GivenCustomFormats();
profile.FormatItems = CustomFormatsFixture.GetSampleFormatItems();
profile.MinFormatScore = 0;
_remoteMovie.Series.QualityProfile = profile;
Console.WriteLine(profile.ToJson());
}
private void GivenLanguageProfile(LanguageProfile profile)
{
_remoteMovie.Series.LanguageProfile = profile;
Console.WriteLine(profile.ToJson());
}
private void GivenFileQuality(QualityModel quality, Language language)
{
_remoteMovie.Episodes.First().EpisodeFile = Builder<EpisodeFile>.CreateNew().With(x => x.Quality = quality).With(x => x.Language = language).Build();
}
private void GivenNewQuality(QualityModel quality)
{
_remoteMovie.ParsedEpisodeInfo.Quality = quality;
}
private void GivenOldCustomFormats(List<CustomFormat> formats)
{
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(x => x.ParseCustomFormat(It.IsAny<EpisodeFile>()))
.Returns(formats);
}
private void GivenNewCustomFormats(List<CustomFormat> formats)
{
_remoteMovie.CustomFormats = formats;
}
private void GivenCustomFormatHigher()
{
_customFormat = new CustomFormat("My Format", new ResolutionSpecification { Value = (int)Resolution.R1080p }) { Id = 1 };
CustomFormatsFixture.GivenCustomFormats(_customFormat);
}
[Test] [Test]
public void should_return_true_if_current_episode_is_less_than_cutoff() public void should_return_true_if_current_episode_is_less_than_cutoff()
{ {
Subject.CutoffNotMet( GivenProfile(new QualityProfile
new QualityProfile {
{ Cutoff = Quality.Bluray1080p.Id,
Cutoff = Quality.Bluray1080p.Id, Items = Qualities.QualityFixture.GetDefaultQualities(),
Items = Qualities.QualityFixture.GetDefaultQualities(), UpgradeAllowed = true
UpgradeAllowed = true });
},
new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Languages = LanguageFixture.GetDefaultLanguages(Language.English), Languages = LanguageFixture.GetDefaultLanguages(Language.English),
Cutoff = Language.English, Cutoff = Language.English,
UpgradeAllowed = true UpgradeAllowed = true
}, });
new QualityModel(Quality.DVD, new Revision(version: 2)),
Language.English, GivenFileQuality(new QualityModel(Quality.DVD, new Revision(version: 2)), Language.English);
NoPreferredWordScore).Should().BeTrue(); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue();
} }
[Test] [Test]
public void should_return_false_if_current_episode_is_equal_to_cutoff() public void should_return_false_if_current_episode_is_equal_to_cutoff()
{ {
Subject.CutoffNotMet( GivenProfile(new QualityProfile
new QualityProfile {
{ Cutoff = Quality.HDTV720p.Id,
Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities(),
Items = Qualities.QualityFixture.GetDefaultQualities(), UpgradeAllowed = true
UpgradeAllowed = true });
},
new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Languages = LanguageFixture.GetDefaultLanguages(Language.English), Languages = LanguageFixture.GetDefaultLanguages(Language.English),
Cutoff = Language.English, Cutoff = Language.English,
UpgradeAllowed = true UpgradeAllowed = true
}, });
new QualityModel(Quality.HDTV720p, new Revision(version: 2)),
Language.English, GivenFileQuality(new QualityModel(Quality.HDTV720p, new Revision(version: 2)), Language.English);
NoPreferredWordScore).Should().BeFalse(); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
} }
[Test] [Test]
public void should_return_false_if_current_episode_is_greater_than_cutoff() public void should_return_false_if_current_episode_is_greater_than_cutoff()
{ {
Subject.CutoffNotMet( GivenProfile(new QualityProfile
new QualityProfile {
{ Cutoff = Quality.HDTV720p.Id,
Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities(),
Items = Qualities.QualityFixture.GetDefaultQualities(), UpgradeAllowed = true
UpgradeAllowed = true });
},
new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Languages = LanguageFixture.GetDefaultLanguages(Language.English), Languages = LanguageFixture.GetDefaultLanguages(Language.English),
Cutoff = Language.English, Cutoff = Language.English,
UpgradeAllowed = true UpgradeAllowed = true
}, });
new QualityModel(Quality.Bluray1080p, new Revision(version: 2)),
Language.English, GivenFileQuality(new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), Language.English);
NoPreferredWordScore).Should().BeFalse(); Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
} }
[Test] [Test]
public void should_return_true_when_new_episode_is_proper_but_existing_is_not() public void should_return_true_when_new_episode_is_proper_but_existing_is_not()
{ {
Subject.CutoffNotMet( GivenProfile(new QualityProfile
new QualityProfile {
{ Cutoff = Quality.HDTV720p.Id,
Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities(),
Items = Qualities.QualityFixture.GetDefaultQualities(), UpgradeAllowed = true
UpgradeAllowed = true });
},
new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Languages = LanguageFixture.GetDefaultLanguages(Language.English), Languages = LanguageFixture.GetDefaultLanguages(Language.English),
Cutoff = Language.English, Cutoff = Language.English,
UpgradeAllowed = true UpgradeAllowed = true
}, });
new QualityModel(Quality.HDTV720p, new Revision(version: 1)),
Language.English, GivenFileQuality(new QualityModel(Quality.HDTV720p, new Revision(version: 1)), Language.English);
NoPreferredWordScore, GivenNewQuality(new QualityModel(Quality.HDTV720p, new Revision(version: 2)));
new QualityModel(Quality.HDTV720p, new Revision(version: 2)), Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue();
NoPreferredWordScore).Should().BeTrue();
} }
[Test] [Test]
public void should_return_false_if_cutoff_is_met_and_quality_is_higher() public void should_return_false_if_cutoff_is_met_and_quality_is_higher()
{ {
Subject.CutoffNotMet( GivenProfile(new QualityProfile
new QualityProfile {
{ Cutoff = Quality.HDTV720p.Id,
Cutoff = Quality.HDTV720p.Id, Items = Qualities.QualityFixture.GetDefaultQualities(),
Items = Qualities.QualityFixture.GetDefaultQualities(), UpgradeAllowed = true
UpgradeAllowed = true });
},
new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Languages = LanguageFixture.GetDefaultLanguages(Language.English), Languages = LanguageFixture.GetDefaultLanguages(Language.English),
Cutoff = Language.English, Cutoff = Language.English,
UpgradeAllowed = true UpgradeAllowed = true
}, });
new QualityModel(Quality.HDTV720p, new Revision(version: 2)),
Language.English, GivenFileQuality(new QualityModel(Quality.HDTV720p, new Revision(version: 2)), Language.English);
NoPreferredWordScore, GivenNewQuality(new QualityModel(Quality.Bluray1080p, new Revision(version: 2)));
new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
NoPreferredWordScore).Should().BeFalse();
} }
[Test] [Test]
public void should_return_true_if_quality_cutoff_is_met_and_quality_is_higher_but_language_is_not_met() public void should_return_true_if_quality_cutoff_is_met_and_quality_is_higher_but_language_is_not_met()
{ {
QualityProfile profile = new QualityProfile GivenProfile(new QualityProfile
{ {
Cutoff = Quality.HDTV720p.Id, Cutoff = Quality.HDTV720p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true UpgradeAllowed = true
}; });
LanguageProfile langProfile = new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Cutoff = Language.Spanish,
Languages = LanguageFixture.GetDefaultLanguages(), Languages = LanguageFixture.GetDefaultLanguages(),
Cutoff = Language.Spanish,
UpgradeAllowed = true UpgradeAllowed = true
}; });
Subject.CutoffNotMet(profile, GivenFileQuality(new QualityModel(Quality.HDTV720p, new Revision(version: 2)), Language.English);
langProfile, GivenNewQuality(new QualityModel(Quality.Bluray1080p, new Revision(version: 2)));
new QualityModel(Quality.HDTV720p, new Revision(version: 2)), Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue();
Language.English,
NoPreferredWordScore,
new QualityModel(Quality.Bluray1080p, new Revision(version: 2)),
NoPreferredWordScore).Should().BeTrue();
} }
[Test] [Test]
public void should_return_false_if_cutoff_is_met_and_quality_is_higher_and_language_is_met() public void should_return_false_if_quality_cutoff_is_met_and_quality_is_higher_but_language_is_met()
{ {
QualityProfile profile = new QualityProfile GivenProfile(new QualityProfile
{ {
Cutoff = Quality.HDTV720p.Id, Cutoff = Quality.HDTV720p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true UpgradeAllowed = true
}; });
LanguageProfile langProfile = new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Cutoff = Language.Spanish,
Languages = LanguageFixture.GetDefaultLanguages(), Languages = LanguageFixture.GetDefaultLanguages(),
Cutoff = Language.Spanish,
UpgradeAllowed = true UpgradeAllowed = true
}; });
Subject.CutoffNotMet( GivenFileQuality(new QualityModel(Quality.HDTV720p, new Revision(version: 2)), Language.Spanish);
profile, GivenNewQuality(new QualityModel(Quality.Bluray1080p, new Revision(version: 2)));
langProfile, Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
new QualityModel(Quality.HDTV720p, new Revision(version: 2)),
Language.Spanish,
NoPreferredWordScore,
new QualityModel(Quality.Bluray1080p, new Revision(version: 2)),
NoPreferredWordScore).Should().BeFalse();
} }
[Test] [Test]
public void should_return_false_if_cutoff_is_met_and_quality_is_higher_and_language_is_higher() public void should_return_false_if_cutoff_is_met_and_quality_is_higher_and_language_is_higher()
{ {
QualityProfile profile = new QualityProfile GivenProfile(new QualityProfile
{ {
Cutoff = Quality.HDTV720p.Id, Cutoff = Quality.HDTV720p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true UpgradeAllowed = true
}; });
LanguageProfile langProfile = new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Cutoff = Language.Spanish,
Languages = LanguageFixture.GetDefaultLanguages(), Languages = LanguageFixture.GetDefaultLanguages(),
Cutoff = Language.Spanish,
UpgradeAllowed = true UpgradeAllowed = true
}; });
Subject.CutoffNotMet( GivenFileQuality(new QualityModel(Quality.HDTV720p, new Revision(version: 2)), Language.French);
profile, GivenNewQuality(new QualityModel(Quality.Bluray1080p, new Revision(version: 2)));
langProfile, Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
new QualityModel(Quality.HDTV720p, new Revision(version: 2)),
Language.French,
NoPreferredWordScore,
new QualityModel(Quality.Bluray1080p, new Revision(version: 2)),
NoPreferredWordScore).Should().BeFalse();
} }
[Test] [Test]
public void should_return_true_if_cutoff_is_not_met_and_new_quality_is_higher_and_language_is_higher() public void should_return_true_if_cutoff_is_not_met_and_new_quality_is_higher_and_language_is_higher()
{ {
QualityProfile profile = new QualityProfile GivenProfile(new QualityProfile
{ {
Cutoff = Quality.HDTV720p.Id, Cutoff = Quality.HDTV720p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true UpgradeAllowed = true
}; });
LanguageProfile langProfile = new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Cutoff = Language.Spanish,
Languages = LanguageFixture.GetDefaultLanguages(), Languages = LanguageFixture.GetDefaultLanguages(),
Cutoff = Language.Spanish,
UpgradeAllowed = true UpgradeAllowed = true
}; });
Subject.CutoffNotMet( GivenFileQuality(new QualityModel(Quality.SDTV, new Revision(version: 2)), Language.French);
profile, GivenNewQuality(new QualityModel(Quality.Bluray1080p, new Revision(version: 2)));
langProfile, Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue();
new QualityModel(Quality.SDTV, new Revision(version: 2)),
Language.French,
NoPreferredWordScore,
new QualityModel(Quality.Bluray1080p, new Revision(version: 2)),
NoPreferredWordScore).Should().BeTrue();
} }
[Test] [Test]
public void should_return_true_if_cutoff_is_not_met_and_language_is_higher() public void should_return_true_if_cutoff_is_not_met_and_language_is_higher()
{ {
QualityProfile profile = new QualityProfile GivenProfile(new QualityProfile
{ {
Cutoff = Quality.HDTV720p.Id, Cutoff = Quality.HDTV720p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true UpgradeAllowed = true
}; });
LanguageProfile langProfile = new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Cutoff = Language.Spanish,
Languages = LanguageFixture.GetDefaultLanguages(), Languages = LanguageFixture.GetDefaultLanguages(),
Cutoff = Language.Spanish,
UpgradeAllowed = true UpgradeAllowed = true
}; });
Subject.CutoffNotMet( GivenFileQuality(new QualityModel(Quality.SDTV, new Revision(version: 2)), Language.French);
profile, Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue();
langProfile,
new QualityModel(Quality.SDTV, new Revision(version: 2)),
Language.French,
NoPreferredWordScore).Should().BeTrue();
} }
[Test] [Test]
public void should_return_true_if_cutoffs_are_met_and_score_is_higher() public void should_return_false_if_custom_formats_is_met_and_quality_and_format_higher()
{ {
QualityProfile profile = new QualityProfile GivenProfile(new QualityProfile
{ {
Cutoff = Quality.HDTV720p.Id, Cutoff = Quality.HDTV720p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
MinFormatScore = 0,
FormatItems = CustomFormatsFixture.GetSampleFormatItems("My Format"),
UpgradeAllowed = true UpgradeAllowed = true
}; });
LanguageProfile langProfile = new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Cutoff = Language.Spanish, Languages = LanguageFixture.GetDefaultLanguages(Language.English),
Languages = LanguageFixture.GetDefaultLanguages(), Cutoff = Language.English,
UpgradeAllowed = true UpgradeAllowed = true
}; });
Subject.CutoffNotMet( GivenFileQuality(new QualityModel(Quality.HDTV720p), Language.English);
profile, GivenNewQuality(new QualityModel(Quality.Bluray1080p));
langProfile,
new QualityModel(Quality.HDTV720p, new Revision(version: 2)), GivenCustomFormatHigher();
Language.Spanish,
NoPreferredWordScore, GivenOldCustomFormats(new List<CustomFormat>());
new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), GivenNewCustomFormats(new List<CustomFormat> { _customFormat });
10).Should().BeTrue();
Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
} }
[Test] [Test]
public void should_return_true_if_cutoffs_are_met_but_is_a_revision_upgrade() public void should_return_true_if_cutoffs_are_met_but_is_a_revision_upgrade()
{ {
QualityProfile profile = new QualityProfile GivenProfile(new QualityProfile
{ {
Cutoff = Quality.HDTV1080p.Id, Cutoff = Quality.HDTV1080p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true UpgradeAllowed = true
}; });
LanguageProfile langProfile = new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Languages = LanguageFixture.GetDefaultLanguages(Language.English),
Cutoff = Language.English, Cutoff = Language.English,
Languages = LanguageFixture.GetDefaultLanguages(),
UpgradeAllowed = true UpgradeAllowed = true
}; });
Subject.CutoffNotMet( GivenFileQuality(new QualityModel(Quality.WEBDL1080p, new Revision(version: 1)), Language.English);
profile, GivenNewQuality(new QualityModel(Quality.WEBDL1080p, new Revision(version: 2)));
langProfile,
new QualityModel(Quality.WEBDL1080p, new Revision(version: 1)), Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue();
Language.English,
NoPreferredWordScore,
new QualityModel(Quality.WEBDL1080p, new Revision(version: 2)),
NoPreferredWordScore).Should().BeTrue();
} }
[Test] [Test]
public void should_return_false_if_language_profile_does_not_allow_upgrades_but_cutoff_is_set_to_highest_language_and_quality_cutoff_is_met() public void should_return_false_if_quality_profile_does_not_allow_upgrades_but_cutoff_is_set_to_highest_quality()
{ {
QualityProfile profile = new QualityProfile GivenProfile(new QualityProfile
{ {
Cutoff = Quality.WEBDL1080p.Id, Cutoff = Quality.RAWHD.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
};
LanguageProfile langProfile = new LanguageProfile
{
Cutoff = Language.Arabic,
Languages = LanguageFixture.GetDefaultLanguages(Language.Spanish, Language.English, Language.Arabic),
UpgradeAllowed = false
};
Subject.CutoffNotMet(
profile,
langProfile,
new QualityModel(Quality.WEBDL1080p),
Language.English,
NoPreferredWordScore,
new QualityModel(Quality.Bluray1080p),
NoPreferredWordScore).Should().BeFalse();
}
[Test]
public void should_return_false_if_quality_profile_does_not_allow_upgrades_but_cutoff_is_set_to_highest_quality_and_language_cutoff_is_met()
{
QualityProfile profile = new QualityProfile
{
Cutoff = Quality.WEBDL1080p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = false UpgradeAllowed = false
}; });
LanguageProfile langProfile = new LanguageProfile GivenLanguageProfile(new LanguageProfile
{ {
Languages = LanguageFixture.GetDefaultLanguages(Language.English),
Cutoff = Language.English, Cutoff = Language.English,
Languages = LanguageFixture.GetDefaultLanguages(Language.Spanish, Language.English, Language.Arabic),
UpgradeAllowed = true UpgradeAllowed = true
}; });
Subject.CutoffNotMet( GivenFileQuality(new QualityModel(Quality.WEBDL1080p), Language.English);
profile, GivenNewQuality(new QualityModel(Quality.Bluray1080p));
langProfile,
new QualityModel(Quality.WEBDL1080p), Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeFalse();
Language.English,
NoPreferredWordScore,
new QualityModel(Quality.Bluray1080p),
NoPreferredWordScore).Should().BeFalse();
} }
} }
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
@ -471,15 +471,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p), Language.English); var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p), Language.English);
var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p), Language.English); var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p), Language.English);
remoteEpisode1.PreferredWordScore = 10; remoteEpisode1.CustomFormatScore = 10;
remoteEpisode2.PreferredWordScore = 0; remoteEpisode2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode1));
decisions.Add(new DownloadDecision(remoteEpisode2)); decisions.Add(new DownloadDecision(remoteEpisode2));
var qualifiedReports = Subject.PrioritizeDecisions(decisions); var qualifiedReports = Subject.PrioritizeDecisions(decisions);
qualifiedReports.First().RemoteEpisode.PreferredWordScore.Should().Be(10); qualifiedReports.First().RemoteEpisode.CustomFormatScore.Should().Be(10);
} }
[Test] [Test]
@ -492,8 +492,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1)), Language.English); var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1)), Language.English);
var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(2)), Language.English); var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(2)), Language.English);
remoteEpisode1.PreferredWordScore = 10; remoteEpisode1.CustomFormatScore = 10;
remoteEpisode2.PreferredWordScore = 0; remoteEpisode2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode1));
@ -513,8 +513,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1)), Language.English); var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1)), Language.English);
var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(2)), Language.English); var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(2)), Language.English);
remoteEpisode1.PreferredWordScore = 10; remoteEpisode1.CustomFormatScore = 10;
remoteEpisode2.PreferredWordScore = 0; remoteEpisode2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode1));
@ -534,8 +534,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1)), Language.English); var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1)), Language.English);
var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(2)), Language.English); var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(2)), Language.English);
remoteEpisode1.PreferredWordScore = 10; remoteEpisode1.CustomFormatScore = 10;
remoteEpisode2.PreferredWordScore = 0; remoteEpisode2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode1));
@ -544,7 +544,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var qualifiedReports = Subject.PrioritizeDecisions(decisions); var qualifiedReports = Subject.PrioritizeDecisions(decisions);
qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Quality.Should().Be(Quality.WEBDL1080p); qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Quality.Should().Be(Quality.WEBDL1080p);
qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Revision.Version.Should().Be(1); qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Revision.Version.Should().Be(1);
qualifiedReports.First().RemoteEpisode.PreferredWordScore.Should().Be(10); qualifiedReports.First().RemoteEpisode.CustomFormatScore.Should().Be(10);
} }
[Test] [Test]
@ -557,8 +557,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1, 0)), Language.English); var remoteEpisode1 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1, 0)), Language.English);
var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1, 1)), Language.English); var remoteEpisode2 = GivenRemoteEpisode(new List<Episode> { GivenEpisode(1) }, new QualityModel(Quality.WEBDL1080p, new Revision(1, 1)), Language.English);
remoteEpisode1.PreferredWordScore = 10; remoteEpisode1.CustomFormatScore = 10;
remoteEpisode2.PreferredWordScore = 0; remoteEpisode2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteEpisode1)); decisions.Add(new DownloadDecision(remoteEpisode1));
@ -568,7 +568,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Quality.Should().Be(Quality.WEBDL1080p); qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Quality.Should().Be(Quality.WEBDL1080p);
qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Revision.Version.Should().Be(1); qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Revision.Version.Should().Be(1);
qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Revision.Real.Should().Be(0); qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Revision.Real.Should().Be(0);
qualifiedReports.First().RemoteEpisode.PreferredWordScore.Should().Be(10); qualifiedReports.First().RemoteEpisode.CustomFormatScore.Should().Be(10);
} }
[Test] [Test]

View File

@ -2,15 +2,19 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Download.TrackedDownloads;
using NzbDrone.Core.Languages; using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Profiles.Languages;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Queue; using NzbDrone.Core.Queue;
using NzbDrone.Core.Test.CustomFormats;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
@ -33,11 +37,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
Mocker.Resolve<UpgradableSpecification>(); Mocker.Resolve<UpgradableSpecification>();
CustomFormatsFixture.GivenCustomFormats();
_series = Builder<Series>.CreateNew() _series = Builder<Series>.CreateNew()
.With(e => e.QualityProfile = new QualityProfile .With(e => e.QualityProfile = new QualityProfile
{ {
UpgradeAllowed = true, UpgradeAllowed = true,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities(),
FormatItems = CustomFormatsFixture.GetSampleFormatItems(),
MinFormatScore = 0
}) })
.With(l => l.LanguageProfile = new LanguageProfile .With(l => l.LanguageProfile = new LanguageProfile
{ {
@ -69,8 +77,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Series = _series) .With(r => r.Series = _series)
.With(r => r.Episodes = new List<Episode> { _episode }) .With(r => r.Episodes = new List<Episode> { _episode })
.With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD), Language = Language.Spanish }) .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD), Language = Language.Spanish })
.With(r => r.PreferredWordScore = 0) .With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(x => x.ParseCustomFormat(It.IsAny<ParsedEpisodeInfo>()))
.Returns(new List<CustomFormat>());
} }
private void GivenEmptyQueue() private void GivenEmptyQueue()
@ -80,6 +92,13 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.Returns(new List<Queue.Queue>()); .Returns(new List<Queue.Queue>());
} }
private void GivenQueueFormats(List<CustomFormat> formats)
{
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(x => x.ParseCustomFormat(It.IsAny<ParsedEpisodeInfo>()))
.Returns(formats);
}
private void GivenQueue(IEnumerable<RemoteEpisode> remoteEpisodes, TrackedDownloadState trackedDownloadState = TrackedDownloadState.Downloading) private void GivenQueue(IEnumerable<RemoteEpisode> remoteEpisodes, TrackedDownloadState trackedDownloadState = TrackedDownloadState.Downloading)
{ {
var queue = remoteEpisodes.Select(remoteEpisode => new Queue.Queue var queue = remoteEpisodes.Select(remoteEpisode => new Queue.Queue
@ -107,6 +126,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Series = _otherSeries) .With(r => r.Series = _otherSeries)
.With(r => r.Episodes = new List<Episode> { _episode }) .With(r => r.Episodes = new List<Episode> { _episode })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -126,6 +146,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.DVD), Quality = new QualityModel(Quality.DVD),
Language = Language.Spanish Language = Language.Spanish
}) })
.With(r => r.CustomFormats = new List<CustomFormat>())
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.Build(); .Build();
@ -149,6 +170,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.Spanish Language = Language.Spanish
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -170,6 +192,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.English Language = Language.English
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -187,6 +210,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.DVD) Quality = new QualityModel(Quality.DVD)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -194,9 +218,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
} }
[Test] [Test]
public void should_return_true_when_qualities_are_the_same_and_languages_are_the_same_with_higher_preferred_word_score() public void should_return_true_when_qualities_are_the_same_and_languages_are_the_same_with_higher_custom_format_score()
{ {
_remoteEpisode.PreferredWordScore = 1; _remoteEpisode.CustomFormats = new List<CustomFormat> { new CustomFormat("My Format", new ResolutionSpecification { Value = (int)Resolution.R1080p }) { Id = 1 } };
var lowFormat = new List<CustomFormat> { new CustomFormat("Bad Format", new ResolutionSpecification { Value = (int)Resolution.R1080p }) { Id = 2 } };
CustomFormatsFixture.GivenCustomFormats(_remoteEpisode.CustomFormats.First(), lowFormat.First());
_series.QualityProfile.Value.FormatItems = CustomFormatsFixture.GetSampleFormatItems("My Format");
GivenQueueFormats(lowFormat);
var remoteEpisode = Builder<RemoteEpisode>.CreateNew() var remoteEpisode = Builder<RemoteEpisode>.CreateNew()
.With(r => r.Series = _series) .With(r => r.Series = _series)
@ -207,6 +239,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.Spanish, Language = Language.Spanish,
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = lowFormat)
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -225,6 +258,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.Spanish, Language = Language.Spanish,
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -243,6 +277,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.English, Language = Language.English,
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -264,6 +299,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.English Language = Language.English
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -284,6 +320,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.English Language = Language.English
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -302,6 +339,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.English Language = Language.English
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -320,6 +358,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.English Language = Language.English
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
_remoteEpisode.Episodes.Add(_otherEpisode); _remoteEpisode.Episodes.Add(_otherEpisode);
@ -340,6 +379,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.English Language = Language.English
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
_remoteEpisode.Episodes.Add(_otherEpisode); _remoteEpisode.Episodes.Add(_otherEpisode);
@ -354,6 +394,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteEpisodes = Builder<RemoteEpisode>.CreateListOfSize(2) var remoteEpisodes = Builder<RemoteEpisode>.CreateListOfSize(2)
.All() .All()
.With(r => r.Series = _series) .With(r => r.Series = _series)
.With(r => r.CustomFormats = new List<CustomFormat>())
.With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo
{ {
Quality = Quality =
@ -387,6 +428,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.Spanish Language = Language.Spanish
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -408,6 +450,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.English Language = Language.English
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -429,6 +472,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.Spanish Language = Language.Spanish
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }); GivenQueue(new List<RemoteEpisode> { remoteEpisode });
@ -449,6 +493,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Language = Language.Spanish Language = Language.Spanish
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteEpisode> { remoteEpisode }, TrackedDownloadState.FailedPending); GivenQueue(new List<RemoteEpisode> { remoteEpisode }, TrackedDownloadState.FailedPending);

View File

@ -5,6 +5,7 @@ using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.DecisionEngine.Specifications.RssSync; using NzbDrone.Core.DecisionEngine.Specifications.RssSync;
using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Download.Pending;
@ -93,7 +94,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
private void GivenUpgradeForExistingFile() private void GivenUpgradeForExistingFile()
{ {
Mocker.GetMock<IUpgradableSpecification>() Mocker.GetMock<IUpgradableSpecification>()
.Setup(s => s.IsUpgradable(It.IsAny<QualityProfile>(), It.IsAny<LanguageProfile>(), It.IsAny<QualityModel>(), It.IsAny<Language>(), It.IsAny<int>(), It.IsAny<QualityModel>(), It.IsAny<Language>(), It.IsAny<int>())) .Setup(s => s.IsUpgradable(It.IsAny<QualityProfile>(), It.IsAny<LanguageProfile>(), It.IsAny<QualityModel>(), It.IsAny<Language>(), It.IsAny<List<CustomFormat>>(), It.IsAny<QualityModel>(), It.IsAny<Language>(), It.IsAny<List<CustomFormat>>()))
.Returns(true); .Returns(true);
} }

View File

@ -5,6 +5,7 @@ using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.DecisionEngine.Specifications.RssSync; using NzbDrone.Core.DecisionEngine.Specifications.RssSync;
using NzbDrone.Core.History; using NzbDrone.Core.History;
@ -14,6 +15,7 @@ using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Profiles.Languages;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.CustomFormats;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Test.Languages; using NzbDrone.Core.Test.Languages;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
@ -40,6 +42,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
Mocker.Resolve<UpgradableSpecification>(); Mocker.Resolve<UpgradableSpecification>();
_upgradeHistory = Mocker.Resolve<HistorySpecification>(); _upgradeHistory = Mocker.Resolve<HistorySpecification>();
CustomFormatsFixture.GivenCustomFormats();
var singleEpisodeList = new List<Episode> { new Episode { Id = FIRST_EPISODE_ID, SeasonNumber = 12, EpisodeNumber = 3 } }; var singleEpisodeList = new List<Episode> { new Episode { Id = FIRST_EPISODE_ID, SeasonNumber = 12, EpisodeNumber = 3 } };
var doubleEpisodeList = new List<Episode> var doubleEpisodeList = new List<Episode>
{ {
@ -53,6 +57,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
{ {
UpgradeAllowed = true, UpgradeAllowed = true,
Cutoff = Quality.Bluray1080p.Id, Cutoff = Quality.Bluray1080p.Id,
FormatItems = CustomFormatsFixture.GetSampleFormatItems("None"),
MinFormatScore = 0,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities()
}) })
.With(l => l.LanguageProfile = new LanguageProfile .With(l => l.LanguageProfile = new LanguageProfile
@ -67,14 +73,16 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
{ {
Series = _fakeSeries, Series = _fakeSeries,
ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)), Language = Language.English }, ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)), Language = Language.English },
Episodes = doubleEpisodeList Episodes = doubleEpisodeList,
CustomFormats = new List<CustomFormat>()
}; };
_parseResultSingle = new RemoteEpisode _parseResultSingle = new RemoteEpisode
{ {
Series = _fakeSeries, Series = _fakeSeries,
ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)), Language = Language.English }, ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)), Language = Language.English },
Episodes = singleEpisodeList Episodes = singleEpisodeList,
CustomFormats = new List<CustomFormat>()
}; };
_upgradableQuality = new Tuple<QualityModel, Language>(new QualityModel(Quality.SDTV, new Revision(version: 1)), Language.English); _upgradableQuality = new Tuple<QualityModel, Language>(new QualityModel(Quality.SDTV, new Revision(version: 1)), Language.English);
@ -84,6 +92,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
Mocker.GetMock<IConfigService>() Mocker.GetMock<IConfigService>()
.SetupGet(s => s.EnableCompletedDownloadHandling) .SetupGet(s => s.EnableCompletedDownloadHandling)
.Returns(true); .Returns(true);
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(x => x.ParseCustomFormat(It.IsAny<EpisodeHistory>()))
.Returns(new List<CustomFormat>());
} }
private void GivenMostRecentForEpisode(int episodeId, string downloadId, Tuple<QualityModel, Language> quality, DateTime date, EpisodeHistoryEventType eventType) private void GivenMostRecentForEpisode(int episodeId, string downloadId, Tuple<QualityModel, Language> quality, DateTime date, EpisodeHistoryEventType eventType)

View File

@ -4,6 +4,7 @@ using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Languages; using NzbDrone.Core.Languages;
@ -12,6 +13,7 @@ using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Profiles.Languages;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.CustomFormats;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
@ -34,6 +36,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Mocker.Resolve<UpgradableSpecification>(); Mocker.Resolve<UpgradableSpecification>();
_upgradeDisk = Mocker.Resolve<UpgradeDiskSpecification>(); _upgradeDisk = Mocker.Resolve<UpgradeDiskSpecification>();
CustomFormatsFixture.GivenCustomFormats();
_firstFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), DateAdded = DateTime.Now, Language = Language.English }; _firstFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), DateAdded = DateTime.Now, Language = Language.English };
_secondFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), DateAdded = DateTime.Now, Language = Language.English }; _secondFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), DateAdded = DateTime.Now, Language = Language.English };
@ -47,7 +51,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
UpgradeAllowed = true, UpgradeAllowed = true,
Cutoff = Quality.Bluray1080p.Id, Cutoff = Quality.Bluray1080p.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities(),
FormatItems = CustomFormatsFixture.GetSampleFormatItems("None"),
MinFormatScore = 0,
}) })
.With(l => l.LanguageProfile = new LanguageProfile .With(l => l.LanguageProfile = new LanguageProfile
{ {
@ -61,15 +67,21 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
Series = fakeSeries, Series = fakeSeries,
ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)), Language = Language.English }, ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)), Language = Language.English },
Episodes = doubleEpisodeList Episodes = doubleEpisodeList,
CustomFormats = new List<CustomFormat>()
}; };
_parseResultSingle = new RemoteEpisode _parseResultSingle = new RemoteEpisode
{ {
Series = fakeSeries, Series = fakeSeries,
ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)), Language = Language.English }, ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)), Language = Language.English },
Episodes = singleEpisodeList Episodes = singleEpisodeList,
CustomFormats = new List<CustomFormat>()
}; };
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(x => x.ParseCustomFormat(It.IsAny<EpisodeFile>()))
.Returns(new List<CustomFormat>());
} }
private void WithFirstFileUpgradable() private void WithFirstFileUpgradable()
@ -143,11 +155,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_not_be_upgradable_if_revision_downgrade_and_preferred_word_upgrade_if_propers_are_preferred() public void should_not_be_upgradable_if_revision_downgrade_and_preferred_word_upgrade_if_propers_are_preferred()
{ {
Mocker.GetMock<IEpisodeFilePreferredWordCalculator>() Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.Calculate(It.IsAny<Series>(), It.IsAny<EpisodeFile>())) .Setup(s => s.ParseCustomFormat(It.IsAny<EpisodeFile>()))
.Returns(5); .Returns(new List<CustomFormat>());
_parseResultSingle.PreferredWordScore = 10; _parseResultSingle.CustomFormatScore = 10;
_firstFile.Quality = new QualityModel(Quality.WEBDL1080p, new Revision(2)); _firstFile.Quality = new QualityModel(Quality.WEBDL1080p, new Revision(2));
_parseResultSingle.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL1080p); _parseResultSingle.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL1080p);

View File

@ -1,6 +1,8 @@
using System.Collections.Generic;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Languages; using NzbDrone.Core.Languages;
using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Profiles.Languages;
@ -36,8 +38,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new object[] { Quality.WEBDL720p, 1, Language.Spanish, Quality.HDTV720p, 2, Language.French, Quality.WEBDL720p, Language.Spanish, false } new object[] { Quality.WEBDL720p, 1, Language.Spanish, Quality.HDTV720p, 2, Language.French, Quality.WEBDL720p, Language.Spanish, false }
}; };
private static readonly int NoPreferredWordScore = 0;
private void GivenAutoDownloadPropers(ProperDownloadTypes type) private void GivenAutoDownloadPropers(ProperDownloadTypes type)
{ {
Mocker.GetMock<IConfigService>() Mocker.GetMock<IConfigService>()
@ -69,10 +69,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
langProfile, langProfile,
new QualityModel(current, new Revision(version: currentVersion)), new QualityModel(current, new Revision(version: currentVersion)),
Language.English, Language.English,
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(newQuality, new Revision(version: newVersion)), new QualityModel(newQuality, new Revision(version: newVersion)),
Language.English, Language.English,
NoPreferredWordScore) new List<CustomFormat>())
.Should().Be(expected); .Should().Be(expected);
} }
@ -101,10 +101,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
langProfile, langProfile,
new QualityModel(current, new Revision(version: currentVersion)), new QualityModel(current, new Revision(version: currentVersion)),
currentLanguage, currentLanguage,
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(newQuality, new Revision(version: newVersion)), new QualityModel(newQuality, new Revision(version: newVersion)),
newLanguage, newLanguage,
NoPreferredWordScore) new List<CustomFormat>())
.Should().Be(expected); .Should().Be(expected);
} }
@ -129,10 +129,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
langProfile, langProfile,
new QualityModel(Quality.DVD, new Revision(version: 1)), new QualityModel(Quality.DVD, new Revision(version: 1)),
Language.English, Language.English,
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.DVD, new Revision(version: 2)), new QualityModel(Quality.DVD, new Revision(version: 2)),
Language.English, Language.English,
NoPreferredWordScore) new List<CustomFormat>())
.Should().BeTrue(); .Should().BeTrue();
} }
@ -157,10 +157,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
langProfile, langProfile,
new QualityModel(Quality.DVD, new Revision(version: 1)), new QualityModel(Quality.DVD, new Revision(version: 1)),
Language.English, Language.English,
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.DVD, new Revision(version: 2)), new QualityModel(Quality.DVD, new Revision(version: 2)),
Language.English, Language.English,
NoPreferredWordScore) new List<CustomFormat>())
.Should().BeFalse(); .Should().BeFalse();
} }
@ -183,10 +183,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
langProfile, langProfile,
new QualityModel(Quality.HDTV720p, new Revision(version: 1)), new QualityModel(Quality.HDTV720p, new Revision(version: 1)),
Language.English, Language.English,
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.HDTV720p, new Revision(version: 1)), new QualityModel(Quality.HDTV720p, new Revision(version: 1)),
Language.English, Language.English,
NoPreferredWordScore) new List<CustomFormat>())
.Should().BeFalse(); .Should().BeFalse();
} }
} }

View File

@ -6,12 +6,12 @@ using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Releases;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
@ -43,7 +43,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeFileMovingServiceTests
.Build(); .Build();
Mocker.GetMock<IBuildFileNames>() Mocker.GetMock<IBuildFileNames>()
.Setup(s => s.BuildFilePath(It.IsAny<List<Episode>>(), It.IsAny<Series>(), It.IsAny<EpisodeFile>(), It.IsAny<string>(), It.IsAny<NamingConfig>(), It.IsAny<PreferredWordMatchResults>())) .Setup(s => s.BuildFilePath(It.IsAny<List<Episode>>(), It.IsAny<Series>(), It.IsAny<EpisodeFile>(), It.IsAny<string>(), It.IsAny<NamingConfig>(), It.IsAny<List<CustomFormat>>()))
.Returns(@"C:\Test\TV\Series\Season 01\File Name.avi".AsOsAgnostic()); .Returns(@"C:\Test\TV\Series\Season 01\File Name.avi".AsOsAgnostic());
Mocker.GetMock<IBuildFileNames>() Mocker.GetMock<IBuildFileNames>()

View File

@ -1,152 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Profiles.Releases;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.MediaFiles
{
[TestFixture]
public class EpisodeFilePreferredWordCalculatorFixture : CoreTest<EpisodeFilePreferredWordCalculator>
{
private readonly KeyValuePair<string, int> _positiveScore = new KeyValuePair<string, int>("Positive", 10);
private readonly KeyValuePair<string, int> _negativeScore = new KeyValuePair<string, int>("Negative", -10);
private KeyValuePair<string, int> _neutralScore = new KeyValuePair<string, int>("Neutral", 0);
private Series _series;
private EpisodeFile _episodeFile;
[SetUp]
public void Setup()
{
_series = Builder<Series>.CreateNew().Build();
_episodeFile = Builder<EpisodeFile>.CreateNew().Build();
Mocker.GetMock<IPreferredWordService>()
.Setup(s => s.GetMatchingPreferredWordsAndScores(It.IsAny<Series>(), It.IsAny<string>(), 0))
.Returns(new List<KeyValuePair<string, int>>());
}
private void GivenPreferredWordScore(string title, params KeyValuePair<string, int>[] matches)
{
Mocker.GetMock<IPreferredWordService>()
.Setup(s => s.GetMatchingPreferredWordsAndScores(It.IsAny<Series>(), title, 0))
.Returns(matches.ToList());
}
[Test]
public void should_return_score_for_relative_file_name_when_it_is_higher_than_scene_name()
{
GivenPreferredWordScore(_episodeFile.SceneName, _positiveScore);
GivenPreferredWordScore(_episodeFile.RelativePath, _positiveScore, _positiveScore);
Subject.Calculate(_series, _episodeFile).Should().Be(20);
}
[Test]
public void should_return_score_for_full_file_name_when_relative_file_name_is_not_available()
{
_episodeFile.SceneName = null;
_episodeFile.RelativePath = null;
GivenPreferredWordScore(_episodeFile.Path, _positiveScore, _positiveScore);
Subject.Calculate(_series, _episodeFile).Should().Be(20);
}
[Test]
public void should_return_score_for_relative_file_name_when_scene_name_is_null()
{
_episodeFile.SceneName = null;
GivenPreferredWordScore(_episodeFile.RelativePath, _positiveScore, _positiveScore);
Subject.Calculate(_series, _episodeFile).Should().Be(20);
}
[Test]
public void should_return_score_for_scene_name_when_higher_than_relative_file_name()
{
GivenPreferredWordScore(_episodeFile.SceneName, _positiveScore, _positiveScore, _positiveScore);
GivenPreferredWordScore(_episodeFile.RelativePath, _positiveScore, _positiveScore);
Subject.Calculate(_series, _episodeFile).Should().Be(30);
}
[Test]
public void should_return_score_for_relative_file_if_available()
{
GivenPreferredWordScore(_episodeFile.RelativePath, _positiveScore, _positiveScore);
GivenPreferredWordScore(_episodeFile.Path, _positiveScore, _positiveScore, _positiveScore);
Subject.Calculate(_series, _episodeFile).Should().Be(20);
}
[Test]
public void should_return_score_for_original_path_folder_name_if_highest()
{
var folderName = "folder-name";
var fileName = "file-name";
_episodeFile.OriginalFilePath = Path.Combine(folderName, fileName);
GivenPreferredWordScore(_episodeFile.RelativePath, _positiveScore);
GivenPreferredWordScore(_episodeFile.Path, _positiveScore, _positiveScore);
GivenPreferredWordScore(folderName, _positiveScore, _positiveScore, _positiveScore);
GivenPreferredWordScore(fileName, _positiveScore, _positiveScore);
Subject.Calculate(_series, _episodeFile).Should().Be(30);
}
[Test]
public void should_return_score_for_original_path_file_name_if_highest()
{
var folderName = "folder-name";
var fileName = "file-name";
_episodeFile.OriginalFilePath = Path.Combine(folderName, fileName);
GivenPreferredWordScore(_episodeFile.RelativePath, _positiveScore);
GivenPreferredWordScore(_episodeFile.Path, _positiveScore, _positiveScore);
GivenPreferredWordScore(folderName, _positiveScore, _positiveScore);
GivenPreferredWordScore(fileName, _positiveScore, _positiveScore, _positiveScore);
Subject.Calculate(_series, _episodeFile).Should().Be(30);
}
[Test]
public void should_return_negative_score_if_0_result_has_no_matches()
{
var folderName = "folder-name";
var fileName = "file-name";
_episodeFile.OriginalFilePath = Path.Combine(folderName, fileName);
GivenPreferredWordScore(_episodeFile.RelativePath, _negativeScore);
GivenPreferredWordScore(fileName);
Subject.Calculate(_series, _episodeFile).Should().Be(-10);
}
[Test]
public void should_return_0_score_if_0_result_has_matches()
{
var folderName = "folder-name";
var fileName = "file-name";
_episodeFile.OriginalFilePath = Path.Combine(folderName, fileName);
GivenPreferredWordScore(_episodeFile.RelativePath, _negativeScore);
GivenPreferredWordScore(_episodeFile.Path, _negativeScore);
GivenPreferredWordScore(folderName, _negativeScore);
GivenPreferredWordScore(fileName, _neutralScore);
Subject.Calculate(_series, _episodeFile).Should().Be(0);
}
}
}

View File

@ -1,18 +1,22 @@
using System.Collections.Generic;
using System.Linq; using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using NzbDrone.Core.Languages; using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications;
using NzbDrone.Core.Parser;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Profiles.Languages;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Profiles.Releases;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.CustomFormats;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv; using NzbDrone.Core.Tv;
@ -283,15 +287,24 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications
[Test] [Test]
public void should_return_false_if_it_is_a_preferred_word_downgrade_and_equal_language_and_quality() public void should_return_false_if_it_is_a_preferred_word_downgrade_and_equal_language_and_quality()
{ {
var lowFormat = new List<CustomFormat> { new CustomFormat("Bad Format", new ResolutionSpecification { Value = (int)Resolution.R1080p }) { Id = 2 } };
CustomFormatsFixture.GivenCustomFormats(lowFormat.First());
_series.QualityProfile.Value.FormatItems = CustomFormatsFixture.GetSampleFormatItems();
Mocker.GetMock<IConfigService>() Mocker.GetMock<IConfigService>()
.Setup(s => s.DownloadPropersAndRepacks) .Setup(s => s.DownloadPropersAndRepacks)
.Returns(ProperDownloadTypes.DoNotPrefer); .Returns(ProperDownloadTypes.DoNotPrefer);
Mocker.GetMock<IEpisodeFilePreferredWordCalculator>() Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.Calculate(It.IsAny<Series>(), It.IsAny<EpisodeFile>())) .Setup(s => s.ParseCustomFormat(It.IsAny<EpisodeFile>()))
.Returns(10); .Returns(new List<CustomFormat>());
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.ParseCustomFormat(It.IsAny<ParsedEpisodeInfo>()))
.Returns(lowFormat);
_localEpisode.PreferredWordScore = 5;
_localEpisode.Quality = new QualityModel(Quality.Bluray1080p); _localEpisode.Quality = new QualityModel(Quality.Bluray1080p);
_localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1) _localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1)
@ -318,11 +331,14 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications
.Setup(s => s.DownloadPropersAndRepacks) .Setup(s => s.DownloadPropersAndRepacks)
.Returns(ProperDownloadTypes.DoNotPrefer); .Returns(ProperDownloadTypes.DoNotPrefer);
Mocker.GetMock<IEpisodeFilePreferredWordCalculator>() Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.Calculate(It.IsAny<Series>(), It.IsAny<EpisodeFile>())) .Setup(s => s.ParseCustomFormat(It.IsAny<EpisodeFile>()))
.Returns(10); .Returns(new List<CustomFormat>());
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.ParseCustomFormat(It.IsAny<ParsedEpisodeInfo>()))
.Returns(new List<CustomFormat>());
_localEpisode.PreferredWordScore = 5;
_localEpisode.Quality = new QualityModel(Quality.Bluray2160p); _localEpisode.Quality = new QualityModel(Quality.Bluray2160p);
_localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1) _localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1)
@ -349,11 +365,14 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications
.Setup(s => s.DownloadPropersAndRepacks) .Setup(s => s.DownloadPropersAndRepacks)
.Returns(ProperDownloadTypes.DoNotPrefer); .Returns(ProperDownloadTypes.DoNotPrefer);
Mocker.GetMock<IEpisodeFilePreferredWordCalculator>() Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.Calculate(It.IsAny<Series>(), It.IsAny<EpisodeFile>())) .Setup(s => s.ParseCustomFormat(It.IsAny<EpisodeFile>()))
.Returns(10); .Returns(new List<CustomFormat>());
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.ParseCustomFormat(It.IsAny<ParsedEpisodeInfo>()))
.Returns(new List<CustomFormat>());
_localEpisode.PreferredWordScore = 5;
_localEpisode.Quality = new QualityModel(Quality.Bluray1080p); _localEpisode.Quality = new QualityModel(Quality.Bluray1080p);
_localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1) _localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1)
@ -424,11 +443,14 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications
.Setup(s => s.DownloadPropersAndRepacks) .Setup(s => s.DownloadPropersAndRepacks)
.Returns(ProperDownloadTypes.DoNotPrefer); .Returns(ProperDownloadTypes.DoNotPrefer);
Mocker.GetMock<IEpisodeFilePreferredWordCalculator>() Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.Calculate(It.IsAny<Series>(), It.IsAny<EpisodeFile>())) .Setup(s => s.ParseCustomFormat(It.IsAny<EpisodeFile>()))
.Returns(1); .Returns(new List<CustomFormat>());
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.ParseCustomFormat(It.IsAny<ParsedEpisodeInfo>()))
.Returns(new List<CustomFormat>());
_localEpisode.PreferredWordScore = 5;
_localEpisode.Quality = new QualityModel(Quality.Bluray1080p); _localEpisode.Quality = new QualityModel(Quality.Bluray1080p);
_localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1) _localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1)
@ -454,11 +476,14 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications
.Setup(s => s.DownloadPropersAndRepacks) .Setup(s => s.DownloadPropersAndRepacks)
.Returns(ProperDownloadTypes.DoNotPrefer); .Returns(ProperDownloadTypes.DoNotPrefer);
Mocker.GetMock<IEpisodeFilePreferredWordCalculator>() Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.Calculate(It.IsAny<Series>(), It.IsAny<EpisodeFile>())) .Setup(s => s.ParseCustomFormat(It.IsAny<EpisodeFile>()))
.Returns(5); .Returns(new List<CustomFormat>());
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.ParseCustomFormat(It.IsAny<ParsedEpisodeInfo>()))
.Returns(new List<CustomFormat>());
_localEpisode.PreferredWordScore = 5;
_localEpisode.Quality = new QualityModel(Quality.Bluray1080p); _localEpisode.Quality = new QualityModel(Quality.Bluray1080p);
_localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1) _localEpisode.Episodes = Builder<Episode>.CreateListOfSize(1)

View File

@ -1,8 +1,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -45,6 +46,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
Mocker.GetMock<IQualityDefinitionService>() Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>())) .Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
} }
[TestCase("Florence + the Machine", "Florence + the Machine")] [TestCase("Florence + the Machine", "Florence + the Machine")]

View File

@ -3,6 +3,7 @@ using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -44,6 +45,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
Mocker.GetMock<IQualityDefinitionService>() Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>())) .Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
} }
[TestCase("The Mist", 2018, "The Mist 2018")] [TestCase("The Mist", 2018, "The Mist 2018")]

View File

@ -1,8 +1,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -61,6 +62,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
Mocker.GetMock<IQualityDefinitionService>() Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>())) .Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
} }
[TestCase("Hey, Baby, What's Wrong (1)", "Hey, Baby, What's Wrong (2)", "Hey, Baby, What's Wrong")] [TestCase("Hey, Baby, What's Wrong (1)", "Hey, Baby, What's Wrong (2)", "Hey, Baby, What's Wrong")]

View File

@ -7,6 +7,7 @@ using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
@ -53,6 +54,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
Mocker.GetMock<IQualityDefinitionService>() Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>())) .Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
} }
private void GivenProper() private void GivenProper()

View File

@ -3,6 +3,7 @@ using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -62,6 +63,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
Mocker.GetMock<IQualityDefinitionService>() Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>())) .Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
} }
private void GivenProper() private void GivenProper()

View File

@ -3,6 +3,7 @@ using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -54,6 +55,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
Mocker.GetMock<IQualityDefinitionService>() Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>())) .Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
} }
private void GivenProper() private void GivenProper()

View File

@ -3,6 +3,7 @@ using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -45,6 +46,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
Mocker.GetMock<IQualityDefinitionService>() Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>())) .Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
} }
[Test] [Test]

View File

@ -1,110 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer;
using NzbDrone.Core.Profiles.Releases;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
[TestFixture]
public class PreferredWordsFixture : CoreTest<FileNameBuilder>
{
private Series _series;
private Episode _episode1;
private EpisodeFile _episodeFile;
private NamingConfig _namingConfig;
private PreferredWordMatchResults _preferredWords;
[SetUp]
public void Setup()
{
_series = Builder<Series>
.CreateNew()
.With(s => s.Title = "South Park")
.Build();
_namingConfig = NamingConfig.Default;
_namingConfig.RenameEpisodes = true;
Mocker.GetMock<INamingConfigService>()
.Setup(c => c.GetConfig()).Returns(_namingConfig);
_episode1 = Builder<Episode>.CreateNew()
.With(e => e.Title = "City Sushi")
.With(e => e.SeasonNumber = 15)
.With(e => e.EpisodeNumber = 6)
.With(e => e.AbsoluteEpisodeNumber = 100)
.Build();
_episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" };
_preferredWords = new PreferredWordMatchResults()
{
All = new List<string>()
{
"x265",
"extended"
},
ByReleaseProfile = new Dictionary<string, List<string>>()
{
{
"CodecProfile",
new List<string>()
{
"x265"
}
},
{
"EditionProfile",
new List<string>()
{
"extended"
}
}
}
};
Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
}
[TestCase("{Preferred Words}", "x265 extended")]
[TestCase("{Preferred Words:CodecProfile}", "x265")]
[TestCase("{Preferred Words:EditionProfile}", "extended")]
[TestCase("{Preferred Words:CodecProfile} - {PreferredWords:EditionProfile}", "x265 - extended")]
public void should_replace_PreferredWords(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, preferredWords: _preferredWords)
.Should().Be(expected);
}
[TestCase("{Preferred Words:}", "{Preferred Words:}")]
public void should_not_replace_PreferredWords(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, preferredWords: _preferredWords)
.Should().Be(expected);
}
[TestCase("{Preferred Words:NonexistentProfile}", "")]
public void should_replace_PreferredWords_with_empty_string(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, preferredWords: _preferredWords)
.Should().Be(expected);
}
}
}

View File

@ -1,8 +1,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FizzWare.NBuilder; using FizzWare.NBuilder;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -45,6 +46,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
Mocker.GetMock<IQualityDefinitionService>() Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>())) .Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); .Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
} }
// { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" }; // { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" };

Some files were not shown because too many files have changed in this diff Show More