New: Custom Formats

Co-Authored-By: ta264 <ta264@users.noreply.github.com>
pull/2165/head
Qstick 2 years ago
parent 4a3062deae
commit dbb6ef7664

@ -44,7 +44,8 @@ module.exports = (env) => {
'node_modules' 'node_modules'
], ],
alias: { alias: {
jquery: 'jquery/src/jquery' jquery: 'jquery/src/jquery',
'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate'
}, },
fallback: { fallback: {
buffer: false, buffer: false,

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import AuthorNameLink from 'Author/AuthorNameLink'; import AuthorNameLink from 'Author/AuthorNameLink';
import BookFormats from 'Book/BookFormats';
import BookQuality from 'Book/BookQuality'; import BookQuality from 'Book/BookQuality';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
@ -45,6 +46,7 @@ class BlocklistRow extends Component {
author, author,
sourceTitle, sourceTitle,
quality, quality,
customFormats,
date, date,
protocol, protocol,
indexer, indexer,
@ -110,6 +112,16 @@ class BlocklistRow extends Component {
); );
} }
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<BookFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') { if (name === 'date') {
return ( return (
<RelativeDateCellConnector <RelativeDateCellConnector
@ -174,6 +186,7 @@ BlocklistRow.propTypes = {
author: PropTypes.object.isRequired, author: PropTypes.object.isRequired,
sourceTitle: PropTypes.string.isRequired, sourceTitle: PropTypes.string.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,

@ -9,6 +9,7 @@ import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import formatDateTime from 'Utilities/Date/formatDateTime'; import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge'; import formatAge from 'Utilities/Number/formatAge';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './HistoryDetails.css'; import styles from './HistoryDetails.css';
@ -65,6 +66,7 @@ function HistoryDetails(props) {
const { const {
indexer, indexer,
releaseGroup, releaseGroup,
customFormatScore,
nzbInfoUrl, nzbInfoUrl,
downloadClient, downloadClient,
downloadId, downloadId,
@ -100,7 +102,16 @@ function HistoryDetails(props) {
} }
{ {
!!nzbInfoUrl && customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title="Custom Format Score"
data={formatPreferredWordScore(customFormatScore)}
/> :
null
}
{
nzbInfoUrl ?
<span> <span>
<DescriptionListItemTitle> <DescriptionListItemTitle>
Info URL Info URL
@ -109,7 +120,8 @@ function HistoryDetails(props) {
<DescriptionListItemDescription> <DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link> <Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription> </DescriptionListItemDescription>
</span> </span> :
null
} }
{ {
@ -173,6 +185,7 @@ function HistoryDetails(props) {
if (eventType === 'bookFileImported') { if (eventType === 'bookFileImported') {
const { const {
customFormatScore,
droppedPath, droppedPath,
importedPath importedPath
} = data; } = data;
@ -195,12 +208,22 @@ function HistoryDetails(props) {
} }
{ {
!!importedPath && importedPath ?
<DescriptionListItem <DescriptionListItem
descriptionClassName={styles.description} descriptionClassName={styles.description}
title={translate('ImportedTo')} title={translate('ImportedTo')}
data={importedPath} data={importedPath}
/> /> :
null
}
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title="Custom Format Score"
data={formatPreferredWordScore(customFormatScore)}
/> :
null
} }
</DescriptionList> </DescriptionList>
); );
@ -208,7 +231,8 @@ function HistoryDetails(props) {
if (eventType === 'bookFileDeleted') { if (eventType === 'bookFileDeleted') {
const { const {
reason reason,
customFormatScore
} = data; } = data;
let reasonMessage = ''; let reasonMessage = '';
@ -238,6 +262,15 @@ function HistoryDetails(props) {
title={translate('Reason')} title={translate('Reason')}
data={reasonMessage} data={reasonMessage}
/> />
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title="Custom Format Score"
data={formatPreferredWordScore(customFormatScore)}
/> :
null
}
</DescriptionList> </DescriptionList>
); );
} }

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

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import AuthorNameLink from 'Author/AuthorNameLink'; import AuthorNameLink from 'Author/AuthorNameLink';
import BookFormats from 'Book/BookFormats';
import BookQuality from 'Book/BookQuality'; import BookQuality from 'Book/BookQuality';
import BookTitleLink from 'Book/BookTitleLink'; import BookTitleLink from 'Book/BookTitleLink';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
@ -8,6 +9,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 { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import HistoryDetailsModal from './Details/HistoryDetailsModal'; import HistoryDetailsModal from './Details/HistoryDetailsModal';
import HistoryEventTypeCell from './HistoryEventTypeCell'; import HistoryEventTypeCell from './HistoryEventTypeCell';
import styles from './HistoryRow.css'; import styles from './HistoryRow.css';
@ -54,6 +56,7 @@ class HistoryRow extends Component {
author, author,
book, book,
quality, quality,
customFormats,
qualityCutoffNotMet, qualityCutoffNotMet,
eventType, eventType,
sourceTitle, sourceTitle,
@ -127,6 +130,16 @@ class HistoryRow extends Component {
); );
} }
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<BookFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') { if (name === 'date') {
return ( return (
<RelativeDateCellConnector <RelativeDateCellConnector
@ -158,6 +171,17 @@ class HistoryRow extends Component {
); );
} }
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
{formatPreferredWordScore(data.customFormatScore)}
</TableRowCell>
);
}
if (name === 'releaseGroup') { if (name === 'releaseGroup') {
return ( return (
<TableRowCell <TableRowCell
@ -219,6 +243,7 @@ HistoryRow.propTypes = {
author: PropTypes.object.isRequired, author: PropTypes.object.isRequired,
book: PropTypes.object, book: PropTypes.object,
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,

@ -2,6 +2,7 @@ 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 AuthorNameLink from 'Author/AuthorNameLink'; import AuthorNameLink from 'Author/AuthorNameLink';
import BookFormats from 'Book/BookFormats';
import BookQuality from 'Book/BookQuality'; import BookQuality from 'Book/BookQuality';
import BookTitleLink from 'Book/BookTitleLink'; import BookTitleLink from 'Book/BookTitleLink';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@ -89,6 +90,7 @@ class QueueRow extends Component {
author, author,
book, book,
quality, quality,
customFormats,
protocol, protocol,
indexer, indexer,
outputPath, outputPath,
@ -210,6 +212,16 @@ class QueueRow extends Component {
); );
} }
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<BookFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'protocol') { if (name === 'protocol') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
@ -379,6 +391,7 @@ QueueRow.propTypes = {
author: PropTypes.object, author: PropTypes.object,
book: PropTypes.object, book: PropTypes.object,
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,

@ -13,6 +13,7 @@ import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound'; import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch'; import Switch from 'Components/Router/Switch';
import AddNewItemConnector from 'Search/AddNewItemConnector'; import AddNewItemConnector from 'Search/AddNewItemConnector';
import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector'; import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
@ -175,6 +176,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}

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

@ -25,6 +25,7 @@ import PathInputConnector from './PathInputConnector';
import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector'; import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector'; import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
import TagInputConnector from './TagInputConnector'; import TagInputConnector from './TagInputConnector';
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';
@ -92,6 +93,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;

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

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

@ -46,13 +46,13 @@ class TextTagInputConnector extends Component {
// to oddities with restrictions (as an example). // to oddities with restrictions (as an example).
const newValue = [...valueArray]; const newValue = [...valueArray];
const newTags = split(tag.name); const newTags = tag.name.startsWith('/') ? [tag.name] : split(tag.name);
newTags.forEach((newTag) => { newTags.forEach((newTag) => {
newValue.push(newTag.trim()); newValue.push(newTag.trim());
}); });
onChange({ name, value: newValue.join(',') }); onChange({ name, value: newValue });
}; };
onTagDelete = ({ index }) => { onTagDelete = ({ index }) => {
@ -67,7 +67,7 @@ class TextTagInputConnector extends Component {
onChange({ onChange({
name, name,
value: newValue.join(',') value: newValue
}); });
}; };

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

@ -10,6 +10,7 @@ import { icons } from 'Helpers/Props';
import locationShape from 'Helpers/Props/Shapes/locationShape'; import locationShape from 'Helpers/Props/Shapes/locationShape';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector';
import translate from 'Utilities/String/translate';
import MessagesConnector from './Messages/MessagesConnector'; import MessagesConnector from './Messages/MessagesConnector';
import PageSidebarItem from './PageSidebarItem'; import PageSidebarItem from './PageSidebarItem';
import styles from './PageSidebar.css'; import styles from './PageSidebar.css';
@ -108,7 +109,11 @@ const links = [
to: '/settings/quality' to: '/settings/quality'
}, },
{ {
title: 'Indexers', title: translate('CustomFormats'),
to: '/settings/customformats'
},
{
title: translate('Indexers'),
to: '/settings/indexers' to: '/settings/indexers'
}, },
{ {

@ -196,7 +196,7 @@ class TableOptionsModal extends Component {
<TableOptionsColumnDragSource <TableOptionsColumnDragSource
key={name} key={name}
name={name} name={name}
label={label || columnLabel} label={columnLabel || label}
isVisible={isVisible} isVisible={isVisible}
isModifiable={true} isModifiable={true}
index={index} index={index}
@ -214,7 +214,7 @@ class TableOptionsModal extends Component {
<TableOptionsColumn <TableOptionsColumn
key={name} key={name}
name={name} name={name}
label={label || columnLabel} label={columnLabel || label}
isVisible={isVisible} isVisible={isVisible}
index={index} index={index}
isModifiable={false} isModifiable={false}

@ -56,6 +56,7 @@ import {
faEye as fasEye, faEye as fasEye,
faFastBackward as fasFastBackward, faFastBackward as fasFastBackward,
faFastForward as fasFastForward, faFastForward as fasFastForward,
faFileExport as fasFileExport,
faFileImport as fasFileImport, faFileImport as fasFileImport,
faFileInvoice as farFileInvoice, faFileInvoice as farFileInvoice,
faFilter as fasFilter, faFilter as fasFilter,
@ -147,6 +148,7 @@ export const EDIT = fasWrench;
export const TRACK_FILE = farFileAudio; export const TRACK_FILE = farFileAudio;
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;

@ -19,6 +19,7 @@ export const SELECT = 'select';
export const DYNAMIC_SELECT = 'dynamicSelect'; export const DYNAMIC_SELECT = 'dynamicSelect';
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 UMASK = 'umask'; export const UMASK = 'umask';
@ -44,6 +45,7 @@ export const all = [
DYNAMIC_SELECT, DYNAMIC_SELECT,
TAG, TAG,
TEXT, TEXT,
TEXT_AREA,
TEXT_TAG, TEXT_TAG,
UMASK UMASK
]; ];

@ -65,6 +65,15 @@ const columns = [
isSortable: true, isSortable: true,
isVisible: true isVisible: true
}, },
{
name: 'customFormats',
label: React.createElement(Icon, {
name: icons.INTERACTIVE,
title: translate('CustomFormat')
}),
isSortable: true,
isVisible: true
},
{ {
name: 'rejections', name: 'rejections',
label: React.createElement(Icon, { label: React.createElement(Icon, {

@ -29,3 +29,7 @@
color: var(--disabledColor); color: var(--disabledColor);
} }
.customFormatTooltip {
max-width: 250px;
}

@ -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 BookFormats from 'Book/BookFormats';
import BookQuality from 'Book/BookQuality'; import BookQuality from 'Book/BookQuality';
import FileDetails from 'BookFile/FileDetails'; import FileDetails from 'BookFile/FileDetails';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@ -165,6 +166,7 @@ class InteractiveImportRow extends Component {
quality, quality,
releaseGroup, releaseGroup,
size, size,
customFormats,
rejections, rejections,
additionalFile, additionalFile,
isSelected, isSelected,
@ -286,7 +288,26 @@ class InteractiveImportRow extends Component {
<TableRowCell> <TableRowCell>
{ {
rejections && rejections.length ? customFormats?.length ?
<Popover
anchor={
<Icon name={icons.INTERACTIVE} />
}
title="Formats"
body={
<div className={styles.customFormatTooltip}>
<BookFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/> :
null
}
</TableRowCell>
<TableRowCell>
{
rejections.length ?
<Popover <Popover
anchor={ anchor={
<Icon <Icon
@ -371,6 +392,7 @@ InteractiveImportRow.propTypes = {
releaseGroup: PropTypes.string, releaseGroup: PropTypes.string,
quality: PropTypes.object, quality: PropTypes.object,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
rejections: PropTypes.arrayOf(PropTypes.object).isRequired, rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
audioTags: PropTypes.object.isRequired, audioTags: PropTypes.object.isRequired,
additionalFile: PropTypes.bool.isRequired, additionalFile: PropTypes.bool.isRequired,

@ -5,6 +5,7 @@ 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, sortDirections } from 'Helpers/Props'; import { icons, sortDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import InteractiveSearchRow from './InteractiveSearchRow'; import InteractiveSearchRow from './InteractiveSearchRow';
import styles from './InteractiveSearch.css'; import styles from './InteractiveSearch.css';
@ -52,10 +53,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: translate('CustomFormatScore')
}), }),
isSortable: true, isSortable: true,
isVisible: true isVisible: true

@ -28,8 +28,8 @@
text-align: center; text-align: center;
} }
.preferredWordScore { .customFormatScore {
composes: cell; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 55px; width: 55px;
font-weight: bold; font-weight: bold;

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'; 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 BookFormats from 'Book/BookFormats';
import BookQuality from 'Book/BookQuality'; import BookQuality from 'Book/BookQuality';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
@ -9,10 +10,12 @@ 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 { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatDateTime from 'Utilities/Date/formatDateTime'; import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge'; import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import Peers from './Peers'; import Peers from './Peers';
import styles from './InteractiveSearchRow.css'; import styles from './InteractiveSearchRow.css';
@ -112,7 +115,8 @@ class InteractiveSearchRow extends Component {
seeders, seeders,
leechers, leechers,
quality, quality,
preferredWordScore, customFormatScore,
customFormats,
rejections, rejections,
downloadAllowed, downloadAllowed,
isGrabbing, isGrabbing,
@ -165,9 +169,14 @@ class InteractiveSearchRow extends Component {
<BookQuality quality={quality} /> <BookQuality quality={quality} />
</TableRowCell> </TableRowCell>
<TableRowCell className={styles.preferredWordScore}> <TableRowCell className={styles.customFormatScore}>
{preferredWordScore > 0 && `+${preferredWordScore}`} <Tooltip
{preferredWordScore < 0 && preferredWordScore} anchor={
formatPreferredWordScore(customFormatScore, customFormats.length)
}
tooltip={<BookFormats formats={customFormats} />}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell> </TableRowCell>
<TableRowCell className={styles.rejected}> <TableRowCell className={styles.rejected}>
@ -240,7 +249,8 @@ InteractiveSearchRow.propTypes = {
seeders: PropTypes.number, seeders: PropTypes.number,
leechers: PropTypes.number, leechers: PropTypes.number,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
preferredWordScore: PropTypes.number.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
rejections: PropTypes.arrayOf(PropTypes.string).isRequired, rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
downloadAllowed: PropTypes.bool.isRequired, downloadAllowed: PropTypes.bool.isRequired,
isGrabbing: PropTypes.bool.isRequired, isGrabbing: PropTypes.bool.isRequired,

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

@ -0,0 +1,49 @@
.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;
}
.buttons {
flex: 0 0 auto;
}
.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;
}
.label {
@add-mixin truncate;
composes: label from '~Components/Label.css';
max-width: 100%;
}

@ -0,0 +1,174 @@
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 className={styles.buttons}>
<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
className={styles.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;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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>
{'Readarr supports custom conditions against the release properties below.'}
</div>
<div>
{'Visit the wiki for more details: '}
<Link to="https://wiki.servarr.com/readarr/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;

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

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

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

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

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

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

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

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

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

@ -270,7 +270,7 @@ class MediaManagement extends Component {
]} ]}
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}

@ -114,7 +114,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 = [

@ -40,6 +40,9 @@ function EditDelayProfileModalContent(props) {
enableTorrent, enableTorrent,
usenetDelay, usenetDelay,
torrentDelay, torrentDelay,
bypassIfHighestQuality,
bypassIfAboveCustomFormatScore,
minimumCustomFormatScore,
tags tags
} = item; } = item;
@ -81,7 +84,7 @@ function EditDelayProfileModalContent(props) {
</FormGroup> </FormGroup>
{ {
enableUsenet.value && enableUsenet.value ?
<FormGroup> <FormGroup>
<FormLabel> <FormLabel>
{translate('UsenetDelay')} {translate('UsenetDelay')}
@ -95,11 +98,12 @@ function EditDelayProfileModalContent(props) {
helpText={translate('UsenetDelayHelpText')} helpText={translate('UsenetDelayHelpText')}
onChange={onInputChange} onChange={onInputChange}
/> />
</FormGroup> </FormGroup> :
null
} }
{ {
enableTorrent.value && enableTorrent.value ?
<FormGroup> <FormGroup>
<FormLabel> <FormLabel>
{translate('TorrentDelay')} {translate('TorrentDelay')}
@ -113,7 +117,48 @@ function EditDelayProfileModalContent(props) {
helpText={translate('TorrentDelayHelpText')} helpText={translate('TorrentDelayHelpText')}
onChange={onInputChange} onChange={onInputChange}
/> />
</FormGroup> </FormGroup> :
null
}
<FormGroup>
<FormLabel>{translate('BypassIfHighestQuality')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="bypassIfHighestQuality"
{...bypassIfHighestQuality}
helpText={translate('BypassIfHighestQualityHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('BypassIfAboveCustomFormatScore')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="bypassIfAboveCustomFormatScore"
{...bypassIfAboveCustomFormatScore}
helpText={translate('BypassIfAboveCustomFormatScoreHelpText')}
onChange={onInputChange}
/>
</FormGroup>
{
bypassIfAboveCustomFormatScore.value ?
<FormGroup>
<FormLabel>{translate('MinimumCustomFormatScore')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minimumCustomFormatScore"
{...minimumCustomFormatScore}
helpText={translate('MinimumCustomFormatScoreHelpText')}
onChange={onInputChange}
/>
</FormGroup> :
null
} }
{ {

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

@ -15,11 +15,23 @@ 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 translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
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 {
// //
@ -93,6 +105,7 @@ class EditQualityProfileModalContent extends Component {
isSaving, isSaving,
saveError, saveError,
qualities, qualities,
customFormats,
item, item,
isInUse, isInUse,
onInputChange, onInputChange,
@ -108,7 +121,10 @@ class EditQualityProfileModalContent extends Component {
name, name,
upgradeAllowed, upgradeAllowed,
cutoff, cutoff,
items minFormatScore,
cutoffFormatScore,
items,
formatItems
} = item; } = item;
return ( return (
@ -157,7 +173,7 @@ class EditQualityProfileModalContent extends Component {
<FormGroup size={sizes.EXTRA_SMALL}> <FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}> <FormLabel size={sizes.SMALL}>
Upgrades Allowed {translate('UpgradesAllowed')}
</FormLabel> </FormLabel>
<FormInputGroup <FormInputGroup
@ -186,6 +202,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 Readarr will no longer grab book releases"
onChange={onInputChange}
/>
</FormGroup>
}
<div className={styles.formatItemLarge}>
{getCustomFormatRender(formatItems, ...otherProps)}
</div>
</div> </div>
<div className={styles.formGroupWrapper}> <div className={styles.formGroupWrapper}>
@ -197,9 +251,12 @@ class EditQualityProfileModalContent extends Component {
{...otherProps} {...otherProps}
/> />
</div> </div>
<div className={styles.formatItemSmall}>
{getCustomFormatRender(formatItems, otherProps)}
</div>
</div> </div>
</Form> </Form>
} }
</Measure> </Measure>
</ModalBody> </ModalBody>
@ -209,7 +266,7 @@ class EditQualityProfileModalContent extends Component {
> >
<ModalFooter> <ModalFooter>
{ {
id && id ?
<div <div
className={styles.deleteButtonContainer} className={styles.deleteButtonContainer}
title={isInUse ? translate('IsInUseCantDeleteAQualityProfileThatIsAttachedToAnAuthorOrImportList') : undefined} title={isInUse ? translate('IsInUseCantDeleteAQualityProfileThatIsAttachedToAnAuthorOrImportList') : undefined}
@ -221,7 +278,8 @@ class EditQualityProfileModalContent extends Component {
> >
Delete Delete
</Button> </Button>
</div> </div> :
null
} }
<Button <Button
@ -251,6 +309,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,

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

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

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

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

@ -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="Readarr 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 Readarr 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;

@ -14,8 +14,7 @@ import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './EditReleaseProfileModalContent.css'; import styles from './EditReleaseProfileModalContent.css';
// Tab, enter, and comma const tagInputDelimiters = ['Tab', 'Enter'];
const tagInputDelimiters = [9, 13, 188];
function EditReleaseProfileModalContent(props) { function EditReleaseProfileModalContent(props) {
const { const {
@ -34,8 +33,6 @@ function EditReleaseProfileModalContent(props) {
enabled, enabled,
required, required,
ignored, ignored,
preferred,
includePreferredWhenRenaming,
tags, tags,
indexerId indexerId
} = item; } = item;
@ -96,41 +93,6 @@ function EditReleaseProfileModalContent(props) {
/> />
</FormGroup> </FormGroup>
<FormGroup>
<FormLabel>
{translate('Preferred')}
</FormLabel>
<FormInputGroup
type={inputTypes.KEY_VALUE_LIST}
name="preferred"
helpTexts={[
translate('PreferredHelpTexts1'),
translate('PreferredHelpTexts2'),
translate('PreferredHelpTexts3')
]}
{...preferred}
keyPlaceholder={translate('Term')}
valuePlaceholder={translate('Score')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('IncludePreferredWhenRenaming')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includePreferredWhenRenaming"
helpText={indexerId.value === 0 ? translate('IndexerIdvalue0IncludeInPreferredWordsRenamingFormat') : translate('IndexerIdvalue0OnlySupportedWhenIndexerIsSetToAll')}
{...includePreferredWhenRenaming}
onChange={onInputChange}
isDisabled={indexerId.value !== 0}
/>
</FormGroup>
<FormGroup> <FormGroup>
<FormLabel> <FormLabel>
{translate('Indexer')} {translate('Indexer')}

@ -9,9 +9,8 @@ import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
const newReleaseProfile = { const newReleaseProfile = {
enabled: true, enabled: true,
required: '', required: [],
ignored: '', ignored: [],
preferred: [],
includePreferredWhenRenaming: false, includePreferredWhenRenaming: false,
tags: [], tags: [],
indexerId: 0 indexerId: 0

@ -9,3 +9,9 @@
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 5px; margin-top: 5px;
} }
.label {
composes: label from '~Components/Label.css';
max-width: 100%;
}

@ -1,12 +1,12 @@
import _ from 'lodash'; import _ from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import MiddleTruncate from 'react-middle-truncate';
import Card from 'Components/Card'; import Card from 'Components/Card';
import Label from 'Components/Label'; import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal'; import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList'; import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import split from 'Utilities/String/split';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
import styles from './ReleaseProfile.css'; import styles from './ReleaseProfile.css';
@ -60,7 +60,6 @@ class ReleaseProfile extends Component {
enabled, enabled,
required, required,
ignored, ignored,
preferred,
tags, tags,
indexerId, indexerId,
tagList, tagList,
@ -82,17 +81,22 @@ class ReleaseProfile extends Component {
> >
<div> <div>
{ {
split(required).map((item) => { required.map((item) => {
if (!item) { if (!item) {
return null; return null;
} }
return ( return (
<Label <Label
className={styles.label}
key={item} key={item}
kind={kinds.SUCCESS} kind={kinds.SUCCESS}
> >
{item} <MiddleTruncate
text={item}
start={10}
end={10}
/>
</Label> </Label>
); );
}) })
@ -101,34 +105,22 @@ class ReleaseProfile extends Component {
<div> <div>
{ {
preferred.map((item) => { ignored.map((item) => {
const isPreferred = item.value >= 0;
return (
<Label
key={item.key}
kind={isPreferred ? kinds.DEFAULT : kinds.WARNING}
>
{item.key} {isPreferred && '+'}{item.value}
</Label>
);
})
}
</div>
<div>
{
split(ignored).map((item) => {
if (!item) { if (!item) {
return null; return null;
} }
return ( return (
<Label <Label
className={styles.label}
key={item} key={item}
kind={kinds.DANGER} kind={kinds.DANGER}
> >
{item} <MiddleTruncate
text={item}
start={10}
end={10}
/>
</Label> </Label>
); );
}) })
@ -186,9 +178,8 @@ class ReleaseProfile extends Component {
ReleaseProfile.propTypes = { ReleaseProfile.propTypes = {
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
enabled: PropTypes.bool.isRequired, enabled: PropTypes.bool.isRequired,
required: PropTypes.string.isRequired, required: PropTypes.arrayOf(PropTypes.string).isRequired,
ignored: 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,
@ -198,9 +189,8 @@ ReleaseProfile.propTypes = {
ReleaseProfile.defaultProps = { ReleaseProfile.defaultProps = {
enabled: true, enabled: true,
required: '', required: [],
ignored: '', ignored: [],
preferred: [],
indexerId: 0 indexerId: 0
}; };

@ -47,6 +47,17 @@ function Settings() {
{translate('QualitySettingsSummary')} {translate('QualitySettingsSummary')}
</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"

@ -8,7 +8,6 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import split from 'Utilities/String/split';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import TagDetailsDelayProfile from './TagDetailsDelayProfile'; import TagDetailsDelayProfile from './TagDetailsDelayProfile';
import styles from './TagDetailsModalContent.css'; import styles from './TagDetailsModalContent.css';
@ -126,7 +125,7 @@ function TagDetailsModalContent(props) {
> >
<div> <div>
{ {
split(item.required).map((r) => { item.required.map((r) => {
return ( return (
<Label <Label
key={r} key={r}
@ -141,7 +140,7 @@ function TagDetailsModalContent(props) {
<div> <div>
{ {
split(item.ignored).map((i) => { item.ignored.map((i) => {
return ( return (
<Label <Label
key={i} key={i}

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

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

@ -47,6 +47,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',

@ -1,5 +1,7 @@
import React from 'react';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { filterTypes, sortDirections } from 'Helpers/Props'; import Icon from 'Components/Icon';
import { filterTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@ -50,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',
@ -76,6 +84,20 @@ export const defaultState = {
label: 'Source Title', label: 'Source Title',
isVisible: false isVisible: false
}, },
{
name: 'sourceTitle',
label: 'Source Title',
isVisible: false
},
{
name: 'customFormatScore',
columnLabel: 'Custom Format Score',
label: React.createElement(Icon, {
name: icons.SCORE,
title: 'Custom format score'
}),
isVisible: false
},
{ {
name: 'details', name: 'details',
columnLabel: 'Details', columnLabel: 'Details',

@ -87,6 +87,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: translate('Protocol'), label: translate('Protocol'),

@ -2,6 +2,7 @@ import { createAction } from 'redux-actions';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import translate from 'Utilities/String/translate';
import createFetchHandler from './Creators/createFetchHandler'; import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
@ -196,6 +197,11 @@ export const defaultState = {
type: filterBuilderTypes.EXACT, type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.QUALITY valueType: filterBuilderValueTypes.QUALITY
}, },
{
name: 'customFormatScore',
label: translate('CustomFormatScore'),
type: filterBuilderTypes.NUMBER
},
{ {
name: 'rejectionCount', name: 'rejectionCount',
label: 'Rejection Count', label: 'Rejection Count',

@ -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 development from './Settings/development'; import development from './Settings/development';
import downloadClientOptions from './Settings/downloadClientOptions'; import downloadClientOptions from './Settings/downloadClientOptions';
@ -25,6 +27,8 @@ import remotePathMappings from './Settings/remotePathMappings';
import rootFolders from './Settings/rootFolders'; import rootFolders from './Settings/rootFolders';
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';
@ -60,6 +64,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,
@ -103,6 +109,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,
@ -137,6 +145,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,

@ -0,0 +1,16 @@
function formatPreferredWordScore(input, customFormatsLength = 0) {
const score = Number(input);
if (score > 0) {
return `+${score}`;
}
if (score < 0) {
return score;
}
return customFormatsLength > 0 ? '+0' : '';
}
export default formatPreferredWordScore;

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

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

@ -68,6 +68,7 @@
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-lazyload": "3.2.0", "react-lazyload": "3.2.0",
"react-measure": "2.5.2", "react-measure": "2.5.2",
"react-middle-truncate": "1.0.3",
"react-popper": "1.3.7", "react-popper": "1.3.7",
"react-redux": "7.2.4", "react-redux": "7.2.4",
"react-router": "5.2.0", "react-router": "5.2.0",

@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.CustomFormats
{
public class CustomFormatsTestHelpers : 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>();
}
}
}

@ -0,0 +1,428 @@
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
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat026>("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_not_migrate_if_bad_regex_in_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "[somestring[",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat026>("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
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat026>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
customFormats.Should().HaveCount(1);
customFormats.First().Name.Should().Be("Unnamed_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
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "some",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var releaseProfiles = db.Query<ReleaseProfile026>("SELECT \"Id\" FROM \"ReleaseProfiles\"");
releaseProfiles.Should().HaveCount(1);
}
[Test]
public void should_remove_release_profile_if_no_ignored_or_required()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var releaseProfiles = db.Query<ReleaseProfile026>("SELECT \"Id\" 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<CustomFormat026>("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<CustomFormat026>("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_two_cfs_if_release_profile_has_multiple_terms()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
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<CustomFormat026>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
customFormats.Should().HaveCount(2);
customFormats.First().Name.Should().Be("Unnamed_1_0");
customFormats.Last().Name.Should().Be("Unnamed_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
{
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<QualityProfile026>("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
{
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<QualityProfile026>("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
{
ReplaceIllegalCharacters = false,
StandardBookFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Preferred Words } {Quality Full}",
});
});
var customFormats = db.Query<NamingConfig026>("SELECT \"StandardBookFormat\" FROM \"NamingConfig\"");
customFormats.Should().HaveCount(1);
customFormats.First().StandardBookFormat.Should().Be("{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Custom Formats } {Quality Full}");
}
private class NamingConfig026
{
public string StandardBookFormat { get; set; }
}
private class ReleaseProfile026
{
public int Id { get; set; }
}
private class QualityProfile026
{
public int Id { get; set; }
public string Name { get; set; }
public List<FormatItem026> FormatItems { get; set; }
}
private class FormatItem026
{
public int Format { get; set; }
public int Score { get; set; }
}
private class CustomFormat026
{
public int Id { get; set; }
public string Name { get; set; }
public bool IncludeCustomFormatWhenRenaming { get; set; }
public List<CustomFormatSpec026> Specifications { get; set; }
}
private class CustomFormatSpec026
{
public string Type { get; set; }
public CustomFormatReleaseTitleSpec026 Body { get; set; }
}
private class CustomFormatReleaseTitleSpec026
{
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; }
}
}
}

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Books;
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;
namespace NzbDrone.Core.Test.DecisionEngineTests
{
[TestFixture]
public class CustomFormatAllowedByProfileSpecificationFixture : CoreTest<CustomFormatAllowedbyProfileSpecification>
{
private RemoteBook _remoteAlbum;
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 fakeArtist = Builder<Author>.CreateNew()
.With(c => c.QualityProfile = new QualityProfile
{
Cutoff = Quality.FLAC.Id,
MinFormatScore = 1
})
.Build();
_remoteAlbum = new RemoteBook
{
Author = fakeArtist,
ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) },
};
CustomFormatsTestHelpers.GivenCustomFormats(_format1, _format2);
}
[Test]
public void should_allow_if_format_score_greater_than_min()
{
_remoteAlbum.CustomFormats = new List<CustomFormat> { _format1 };
_remoteAlbum.Author.QualityProfile.Value.FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems(_format1.Name);
_remoteAlbum.CustomFormatScore = _remoteAlbum.Author.QualityProfile.Value.CalculateCustomFormatScore(_remoteAlbum.CustomFormats);
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue();
}
[Test]
public void should_deny_if_format_score_not_greater_than_min()
{
_remoteAlbum.CustomFormats = new List<CustomFormat> { _format2 };
_remoteAlbum.Author.QualityProfile.Value.FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems(_format1.Name);
_remoteAlbum.CustomFormatScore = _remoteAlbum.Author.QualityProfile.Value.CalculateCustomFormatScore(_remoteAlbum.CustomFormats);
Console.WriteLine(_remoteAlbum.CustomFormatScore);
Console.WriteLine(_remoteAlbum.Author.QualityProfile.Value.MinFormatScore);
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse();
}
[Test]
public void should_deny_if_format_score_not_greater_than_min_2()
{
_remoteAlbum.CustomFormats = new List<CustomFormat> { _format2, _format1 };
_remoteAlbum.Author.QualityProfile.Value.FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems(_format1.Name);
_remoteAlbum.CustomFormatScore = _remoteAlbum.Author.QualityProfile.Value.CalculateCustomFormatScore(_remoteAlbum.CustomFormats);
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse();
}
[Test]
public void should_allow_if_all_format_is_defined_in_profile()
{
_remoteAlbum.CustomFormats = new List<CustomFormat> { _format2, _format1 };
_remoteAlbum.Author.QualityProfile.Value.FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems(_format1.Name, _format2.Name);
_remoteAlbum.CustomFormatScore = _remoteAlbum.Author.QualityProfile.Value.CalculateCustomFormatScore(_remoteAlbum.CustomFormats);
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue();
}
[Test]
public void should_deny_if_no_format_was_parsed_and_min_score_positive()
{
_remoteAlbum.CustomFormats = new List<CustomFormat> { };
_remoteAlbum.Author.QualityProfile.Value.FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems(_format1.Name, _format2.Name);
_remoteAlbum.CustomFormatScore = _remoteAlbum.Author.QualityProfile.Value.CalculateCustomFormatScore(_remoteAlbum.CustomFormats);
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse();
}
[Test]
public void should_allow_if_no_format_was_parsed_min_score_is_zero()
{
_remoteAlbum.CustomFormats = new List<CustomFormat> { };
_remoteAlbum.Author.QualityProfile.Value.FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems(_format1.Name, _format2.Name);
_remoteAlbum.Author.QualityProfile.Value.MinFormatScore = 0;
_remoteAlbum.CustomFormatScore = _remoteAlbum.Author.QualityProfile.Value.CalculateCustomFormatScore(_remoteAlbum.CustomFormats);
Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue();
}
}
}

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -11,8 +12,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[TestFixture] [TestFixture]
public class CutoffSpecificationFixture : CoreTest<UpgradableSpecification> public class CutoffSpecificationFixture : CoreTest<UpgradableSpecification>
{ {
private static readonly int NoPreferredWordScore = 0;
[Test] [Test]
public void should_return_true_if_current_book_is_less_than_cutoff() public void should_return_true_if_current_book_is_less_than_cutoff()
{ {
@ -20,10 +19,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new QualityProfile new QualityProfile
{ {
Cutoff = Quality.MP3.Id, Cutoff = Quality.MP3.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
}, },
new List<QualityModel> { new QualityModel(Quality.Unknown, new Revision(version: 2)) }, new List<QualityModel> { new QualityModel(Quality.Unknown, new Revision(version: 2)) },
NoPreferredWordScore).Should().BeTrue(); new List<CustomFormat>()).Should().BeTrue();
} }
[Test] [Test]
@ -33,10 +33,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new QualityProfile new QualityProfile
{ {
Cutoff = Quality.MP3.Id, Cutoff = Quality.MP3.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
}, },
new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 2)) }, new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 2)) },
NoPreferredWordScore).Should().BeFalse(); new List<CustomFormat>()).Should().BeFalse();
} }
[Test] [Test]
@ -46,10 +47,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new QualityProfile new QualityProfile
{ {
Cutoff = Quality.AZW3.Id, Cutoff = Quality.AZW3.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
}, },
new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 2)) }, new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 2)) },
NoPreferredWordScore).Should().BeFalse(); new List<CustomFormat>()).Should().BeFalse();
} }
[Test] [Test]
@ -59,10 +61,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new QualityProfile new QualityProfile
{ {
Cutoff = Quality.MP3.Id, Cutoff = Quality.MP3.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
}, },
new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 1)) }, new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 1)) },
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.MP3, new Revision(version: 2))).Should().BeTrue(); new QualityModel(Quality.MP3, new Revision(version: 2))).Should().BeTrue();
} }
@ -73,45 +76,46 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new QualityProfile new QualityProfile
{ {
Cutoff = Quality.MP3.Id, Cutoff = Quality.MP3.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
}, },
new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 2)) }, new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 2)) },
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse(); new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse();
} }
[Test] [Test]
public void should_return_true_if_cutoffs_are_met_and_score_is_higher() public void should_return_true_if_cutoffs_are_met_but_is_a_revision_upgrade()
{ {
QualityProfile profile = new QualityProfile QualityProfile profile = new QualityProfile
{ {
Cutoff = Quality.MP3.Id, Cutoff = Quality.MP3.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
}; };
Subject.CutoffNotMet( Subject.CutoffNotMet(
profile, profile,
new List<QualityModel> { new QualityModel(Quality.MP3, new Revision(version: 2)) }, new List<QualityModel> { new QualityModel(Quality.FLAC, new Revision(version: 1)) },
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.FLAC, new Revision(version: 2)), new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeTrue();
10).Should().BeTrue();
} }
[Test] [Test]
public void should_return_true_if_cutoffs_are_met_but_is_a_revision_upgrade() public void should_return_false_if_quality_profile_does_not_allow_upgrades_but_cutoff_is_set_to_highest_quality()
{ {
QualityProfile profile = new QualityProfile QualityProfile profile = new QualityProfile
{ {
Cutoff = Quality.MP3.Id, Cutoff = Quality.FLAC.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(), Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = false
}; };
Subject.CutoffNotMet( Subject.CutoffNotMet(
profile, profile,
new List<QualityModel> { new QualityModel(Quality.FLAC, new Revision(version: 1)) }, new List<QualityModel> { new QualityModel(Quality.Unknown, new Revision(version: 1)) },
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.FLAC, new Revision(version: 2)), new QualityModel(Quality.MP3, new Revision(version: 2))).Should().BeFalse();
NoPreferredWordScore).Should().BeTrue();
} }
} }
} }

@ -416,15 +416,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC)); var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC));
var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC)); var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC));
remoteBook1.PreferredWordScore = 10; remoteBook1.CustomFormatScore = 10;
remoteBook2.PreferredWordScore = 0; remoteBook2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteBook1)); decisions.Add(new DownloadDecision(remoteBook1));
decisions.Add(new DownloadDecision(remoteBook2)); decisions.Add(new DownloadDecision(remoteBook2));
var qualifiedReports = Subject.PrioritizeDecisions(decisions); var qualifiedReports = Subject.PrioritizeDecisions(decisions);
qualifiedReports.First().RemoteBook.PreferredWordScore.Should().Be(10); qualifiedReports.First().RemoteBook.CustomFormatScore.Should().Be(10);
} }
[Test] [Test]
@ -437,8 +437,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1))); var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1)));
var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(2))); var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(2)));
remoteBook1.PreferredWordScore = 10; remoteBook1.CustomFormatScore = 10;
remoteBook2.PreferredWordScore = 0; remoteBook2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteBook1)); decisions.Add(new DownloadDecision(remoteBook1));
@ -458,8 +458,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1))); var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1)));
var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(2))); var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(2)));
remoteBook1.PreferredWordScore = 10; remoteBook1.CustomFormatScore = 10;
remoteBook2.PreferredWordScore = 0; remoteBook2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteBook1)); decisions.Add(new DownloadDecision(remoteBook1));
@ -479,8 +479,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1))); var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1)));
var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(2))); var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(2)));
remoteBook1.PreferredWordScore = 10; remoteBook1.CustomFormatScore = 10;
remoteBook2.PreferredWordScore = 0; remoteBook2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteBook1)); decisions.Add(new DownloadDecision(remoteBook1));
@ -489,7 +489,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var qualifiedReports = Subject.PrioritizeDecisions(decisions); var qualifiedReports = Subject.PrioritizeDecisions(decisions);
qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Quality.Should().Be(Quality.FLAC); qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Quality.Should().Be(Quality.FLAC);
qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Revision.Version.Should().Be(1); qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Revision.Version.Should().Be(1);
qualifiedReports.First().RemoteBook.PreferredWordScore.Should().Be(10); qualifiedReports.First().RemoteBook.CustomFormatScore.Should().Be(10);
} }
[Test] [Test]
@ -536,8 +536,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1, 0))); var remoteBook1 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1, 0)));
var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1, 1))); var remoteBook2 = GivenRemoteBook(new List<Book> { GivenBook(1) }, new QualityModel(Quality.FLAC, new Revision(1, 1)));
remoteBook1.PreferredWordScore = 10; remoteBook1.CustomFormatScore = 10;
remoteBook2.PreferredWordScore = 0; remoteBook2.CustomFormatScore = 0;
var decisions = new List<DownloadDecision>(); var decisions = new List<DownloadDecision>();
decisions.Add(new DownloadDecision(remoteBook1)); decisions.Add(new DownloadDecision(remoteBook1));
@ -548,7 +548,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Quality.Should().Be(Quality.FLAC); qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Quality.Should().Be(Quality.FLAC);
qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Revision.Version.Should().Be(1); qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Revision.Version.Should().Be(1);
qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Revision.Real.Should().Be(0); qualifiedReports.First().RemoteBook.ParsedBookInfo.Quality.Revision.Real.Should().Be(0);
qualifiedReports.First().RemoteBook.PreferredWordScore.Should().Be(10); qualifiedReports.First().RemoteBook.CustomFormatScore.Should().Be(10);
} }
} }
} }

@ -2,14 +2,17 @@ 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.Books; using NzbDrone.Core.Books;
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.Parser.Model; using NzbDrone.Core.Parser.Model;
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;
namespace NzbDrone.Core.Test.DecisionEngineTests namespace NzbDrone.Core.Test.DecisionEngineTests
@ -31,11 +34,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
Mocker.Resolve<UpgradableSpecification>(); Mocker.Resolve<UpgradableSpecification>();
CustomFormatsTestHelpers.GivenCustomFormats();
_author = Builder<Author>.CreateNew() _author = Builder<Author>.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 = CustomFormatsTestHelpers.GetSampleFormatItems(),
MinFormatScore = 0
}) })
.Build(); .Build();
@ -59,8 +66,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Author = _author) .With(r => r.Author = _author)
.With(r => r.Books = new List<Book> { _book }) .With(r => r.Books = new List<Book> { _book })
.With(r => r.ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3) }) .With(r => r.ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3) })
.With(r => r.PreferredWordScore = 0) .With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(x => x.ParseCustomFormat(It.IsAny<RemoteBook>(), It.IsAny<long>()))
.Returns(new List<CustomFormat>());
} }
private void GivenEmptyQueue() private void GivenEmptyQueue()
@ -70,6 +81,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<RemoteBook>(), It.IsAny<long>()))
.Returns(formats);
}
private void GivenQueue(IEnumerable<RemoteBook> remoteBooks, TrackedDownloadState trackedDownloadState = TrackedDownloadState.Downloading) private void GivenQueue(IEnumerable<RemoteBook> remoteBooks, TrackedDownloadState trackedDownloadState = TrackedDownloadState.Downloading)
{ {
var queue = remoteBooks.Select(remoteBook => new Queue.Queue var queue = remoteBooks.Select(remoteBook => new Queue.Queue
@ -97,6 +115,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.With(r => r.Author = _otherAuthor) .With(r => r.Author = _otherAuthor)
.With(r => r.Books = new List<Book> { _book }) .With(r => r.Books = new List<Book> { _book })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }); GivenQueue(new List<RemoteBook> { remoteBook });
@ -115,6 +134,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.CustomFormats = new List<CustomFormat>())
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.Build(); .Build();
@ -136,6 +156,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.AZW3) Quality = new QualityModel(Quality.AZW3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }); GivenQueue(new List<RemoteBook> { remoteBook });
@ -153,6 +174,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }); GivenQueue(new List<RemoteBook> { remoteBook });
@ -160,9 +182,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
} }
[Test] [Test]
public void should_return_true_when_qualities_are_the_same_with_higher_preferred_word_score() public void should_return_true_when_qualities_are_the_same_with_higher_custom_format_score()
{ {
_remoteBook.PreferredWordScore = 1; _remoteBook.CustomFormats = new List<CustomFormat> { new CustomFormat("My Format", new ReleaseTitleSpecification { Value = "MP3" }) { Id = 1 } };
var lowFormat = new List<CustomFormat> { new CustomFormat("Bad Format", new ReleaseTitleSpecification { Value = "MP3" }) { Id = 2 } };
CustomFormatsTestHelpers.GivenCustomFormats(_remoteBook.CustomFormats.First(), lowFormat.First());
_author.QualityProfile.Value.FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems("My Format");
GivenQueueFormats(lowFormat);
var remoteBook = Builder<RemoteBook>.CreateNew() var remoteBook = Builder<RemoteBook>.CreateNew()
.With(r => r.Author = _author) .With(r => r.Author = _author)
@ -172,6 +202,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = lowFormat)
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }); GivenQueue(new List<RemoteBook> { remoteBook });
@ -189,6 +220,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }); GivenQueue(new List<RemoteBook> { remoteBook });
@ -208,6 +240,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }); GivenQueue(new List<RemoteBook> { remoteBook });
@ -225,6 +258,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }); GivenQueue(new List<RemoteBook> { remoteBook });
@ -242,6 +276,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
_remoteBook.Books.Add(_otherBook); _remoteBook.Books.Add(_otherBook);
@ -261,6 +296,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
_remoteBook.Books.Add(_otherBook); _remoteBook.Books.Add(_otherBook);
@ -275,6 +311,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
var remoteBooks = Builder<RemoteBook>.CreateListOfSize(2) var remoteBooks = Builder<RemoteBook>.CreateListOfSize(2)
.All() .All()
.With(r => r.Author = _author) .With(r => r.Author = _author)
.With(r => r.CustomFormats = new List<CustomFormat>())
.With(r => r.ParsedBookInfo = new ParsedBookInfo .With(r => r.ParsedBookInfo = new ParsedBookInfo
{ {
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
@ -305,6 +342,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.FLAC) Quality = new QualityModel(Quality.FLAC)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }); GivenQueue(new List<RemoteBook> { remoteBook });
@ -324,6 +362,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Quality = new QualityModel(Quality.MP3) Quality = new QualityModel(Quality.MP3)
}) })
.With(r => r.Release = _releaseInfo) .With(r => r.Release = _releaseInfo)
.With(r => r.CustomFormats = new List<CustomFormat>())
.Build(); .Build();
GivenQueue(new List<RemoteBook> { remoteBook }, TrackedDownloadState.DownloadFailedPending); GivenQueue(new List<RemoteBook> { remoteBook }, TrackedDownloadState.DownloadFailedPending);

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
@ -33,7 +34,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Mocker.SetConstant<ITermMatcherService>(Mocker.Resolve<TermMatcherService>()); Mocker.SetConstant<ITermMatcherService>(Mocker.Resolve<TermMatcherService>());
} }
private void GivenRestictions(string required, string ignored) private void GivenRestictions(List<string> required, List<string> ignored)
{ {
Mocker.GetMock<IReleaseProfileService>() Mocker.GetMock<IReleaseProfileService>()
.Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>())) .Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>()))
@ -60,7 +61,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_be_true_when_title_contains_one_required_term() public void should_be_true_when_title_contains_one_required_term()
{ {
GivenRestictions("WEBRip", null); GivenRestictions(new List<string> { "WEBRip" }, new List<string>());
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue();
} }
@ -68,7 +69,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_be_false_when_title_does_not_contain_any_required_terms() public void should_be_false_when_title_does_not_contain_any_required_terms()
{ {
GivenRestictions("doesnt,exist", null); GivenRestictions(new List<string> { "doesnt", "exist" }, new List<string>());
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse();
} }
@ -76,7 +77,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_be_true_when_title_does_not_contain_any_ignored_terms() public void should_be_true_when_title_does_not_contain_any_ignored_terms()
{ {
GivenRestictions(null, "ignored"); GivenRestictions(new List<string>(), new List<string> { "ignored" });
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue();
} }
@ -84,7 +85,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_be_false_when_title_contains_one_anded_ignored_terms() public void should_be_false_when_title_contains_one_anded_ignored_terms()
{ {
GivenRestictions(null, "edited"); GivenRestictions(new List<string>(), new List<string> { "edited" });
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse();
} }
@ -95,7 +96,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[TestCase("X264,NOTTHERE")] [TestCase("X264,NOTTHERE")]
public void should_ignore_case_when_matching_required(string required) public void should_ignore_case_when_matching_required(string required)
{ {
GivenRestictions(required, null); GivenRestictions(required.Split(',').ToList(), new List<string>());
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue();
} }
@ -106,7 +107,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[TestCase("X264,NOTTHERE")] [TestCase("X264,NOTTHERE")]
public void should_ignore_case_when_matching_ignored(string ignored) public void should_ignore_case_when_matching_ignored(string ignored)
{ {
GivenRestictions(null, ignored); GivenRestictions(new List<string>(), ignored.Split(',').ToList());
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse();
} }
@ -120,7 +121,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>())) .Setup(s => s.EnabledForTags(It.IsAny<HashSet<int>>(), It.IsAny<int>()))
.Returns(new List<ReleaseProfile> .Returns(new List<ReleaseProfile>
{ {
new ReleaseProfile { Required = "320", Ignored = "www.Speed.cd" } new ReleaseProfile { Required = new List<string> { "320" }, Ignored = new List<string> { "www.Speed.cd" } }
}); });
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse();
@ -132,7 +133,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[TestCase(@"/\.WEB/", true)] [TestCase(@"/\.WEB/", true)]
public void should_match_perl_regex(string pattern, bool expected) public void should_match_perl_regex(string pattern, bool expected)
{ {
GivenRestictions(pattern, null); GivenRestictions(pattern.Split(',').ToList(), new List<string>());
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().Be(expected); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().Be(expected);
} }

@ -6,6 +6,7 @@ using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Books; using NzbDrone.Core.Books;
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;
@ -87,7 +88,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<QualityModel>(), It.IsAny<int>(), It.IsAny<QualityModel>(), It.IsAny<int>())) .Setup(s => s.IsUpgradable(It.IsAny<QualityProfile>(), It.IsAny<QualityModel>(), It.IsAny<List<CustomFormat>>(), It.IsAny<QualityModel>(), It.IsAny<List<CustomFormat>>()))
.Returns(true); .Returns(true);
} }
@ -117,8 +118,23 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
} }
[Test] [Test]
public void should_be_true_when_quality_is_last_allowed_in_profile() public void should_be_false_when_quality_is_last_allowed_in_profile_and_bypass_disabled()
{ {
_remoteBook.Release.PublishDate = DateTime.UtcNow;
_remoteBook.ParsedBookInfo.Quality = new QualityModel(Quality.MP3);
_delayProfile.UsenetDelay = 720;
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse();
}
[Test]
public void should_be_true_when_quality_is_last_allowed_in_profile_and_bypass_enabled()
{
_delayProfile.UsenetDelay = 720;
_delayProfile.BypassIfHighestQuality = true;
_remoteBook.Release.PublishDate = DateTime.UtcNow;
_remoteBook.ParsedBookInfo.Quality = new QualityModel(Quality.MP3); _remoteBook.ParsedBookInfo.Quality = new QualityModel(Quality.MP3);
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue();
@ -194,5 +210,43 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse(); Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse();
} }
[Test]
public void should_be_false_when_custom_format_score_is_above_minimum_but_bypass_disabled()
{
_remoteBook.Release.PublishDate = DateTime.UtcNow;
_remoteBook.CustomFormatScore = 100;
_delayProfile.UsenetDelay = 720;
_delayProfile.MinimumCustomFormatScore = 50;
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse();
}
[Test]
public void should_be_false_when_custom_format_score_is_above_minimum_and_bypass_enabled_but_under_minimum()
{
_remoteBook.Release.PublishDate = DateTime.UtcNow;
_remoteBook.CustomFormatScore = 5;
_delayProfile.UsenetDelay = 720;
_delayProfile.BypassIfAboveCustomFormatScore = true;
_delayProfile.MinimumCustomFormatScore = 50;
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeFalse();
}
[Test]
public void should_be_true_when_custom_format_score_is_above_minimum_and_bypass_enabled()
{
_remoteBook.Release.PublishDate = DateTime.UtcNow;
_remoteBook.CustomFormatScore = 100;
_delayProfile.UsenetDelay = 720;
_delayProfile.BypassIfAboveCustomFormatScore = true;
_delayProfile.MinimumCustomFormatScore = 50;
Subject.IsSatisfiedBy(_remoteBook, null).Accepted.Should().BeTrue();
}
} }
} }

@ -6,6 +6,7 @@ using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Books; using NzbDrone.Core.Books;
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;
@ -13,9 +14,10 @@ using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
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;
namespace NzbDrone.Core.Test.DecisionEngineTests namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync
{ {
[TestFixture] [TestFixture]
public class HistorySpecificationFixture : CoreTest<HistorySpecification> public class HistorySpecificationFixture : CoreTest<HistorySpecification>
@ -37,6 +39,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Mocker.Resolve<UpgradableSpecification>(); Mocker.Resolve<UpgradableSpecification>();
_upgradeHistory = Mocker.Resolve<HistorySpecification>(); _upgradeHistory = Mocker.Resolve<HistorySpecification>();
CustomFormatsTestHelpers.GivenCustomFormats();
var singleBookList = new List<Book> { new Book { Id = FIRST_ALBUM_ID } }; var singleBookList = new List<Book> { new Book { Id = FIRST_ALBUM_ID } };
var doubleBookList = new List<Book> var doubleBookList = new List<Book>
{ {
@ -50,6 +54,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
UpgradeAllowed = true, UpgradeAllowed = true,
Cutoff = Quality.MP3.Id, Cutoff = Quality.MP3.Id,
FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems("None"),
MinFormatScore = 0,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities()
}) })
.Build(); .Build();
@ -58,14 +64,16 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
Author = _fakeAuthor, Author = _fakeAuthor,
ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) }, ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) },
Books = doubleBookList Books = doubleBookList,
CustomFormats = new List<CustomFormat>()
}; };
_parseResultSingle = new RemoteBook _parseResultSingle = new RemoteBook
{ {
Author = _fakeAuthor, Author = _fakeAuthor,
ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) }, ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) },
Books = singleBookList Books = singleBookList,
CustomFormats = new List<CustomFormat>()
}; };
_upgradableQuality = new QualityModel(Quality.MP3, new Revision(version: 1)); _upgradableQuality = new QualityModel(Quality.MP3, new Revision(version: 1));
@ -74,6 +82,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
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<EntityHistory>(), It.IsAny<Author>()))
.Returns(new List<CustomFormat>());
} }
private void GivenMostRecentForBook(int bookId, string downloadId, QualityModel quality, DateTime date, EntityHistoryEventType eventType) private void GivenMostRecentForBook(int bookId, string downloadId, QualityModel quality, DateTime date, EntityHistoryEventType eventType)

@ -1,7 +1,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
@ -11,93 +13,197 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[TestFixture] [TestFixture]
public class UpgradeAllowedSpecificationFixture : CoreTest<UpgradableSpecification> public class UpgradeAllowedSpecificationFixture : CoreTest<UpgradableSpecification>
{ {
private CustomFormat _customFormatOne;
private CustomFormat _customFormatTwo;
private QualityProfile _qualityProfile;
[SetUp]
public void Setup()
{
_customFormatOne = new CustomFormat
{
Id = 1,
Name = "One"
};
_customFormatTwo = new CustomFormat
{
Id = 2,
Name = "Two"
};
_qualityProfile = new QualityProfile
{
Cutoff = Quality.FLAC.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = false,
CutoffFormatScore = 100,
FormatItems = new List<ProfileFormatItem>
{
new ProfileFormatItem
{
Format = _customFormatOne,
Score = 50
},
new ProfileFormatItem
{
Format = _customFormatTwo,
Score = 100
}
}
};
}
[Test] [Test]
public void should_return_false_when_quality_is_better_and_upgrade_allowed_is_false_for_quality_profile() public void should_return_false_when_quality_is_better_custom_formats_are_the_same_and_upgrading_is_not_allowed()
{ {
_qualityProfile.UpgradeAllowed = false;
Subject.IsUpgradeAllowed( Subject.IsUpgradeAllowed(
new QualityProfile _qualityProfile,
{
Cutoff = Quality.FLAC.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = false
},
new QualityModel(Quality.MP3), new QualityModel(Quality.MP3),
new QualityModel(Quality.FLAC)) new List<CustomFormat>(),
new QualityModel(Quality.FLAC),
new List<CustomFormat>())
.Should().BeFalse(); .Should().BeFalse();
} }
[Test]
public void should_return_false_when_quality_is_same_and_custom_format_is_upgrade_and_upgrading_is_not_allowed()
{
_qualityProfile.UpgradeAllowed = false;
Subject.IsUpgradeAllowed(
_qualityProfile,
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatOne },
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatTwo })
.Should().BeFalse();
}
[Test]
public void should_return_true_for_custom_format_upgrade_when_upgrading_is_allowed()
{
_qualityProfile.UpgradeAllowed = true;
Subject.IsUpgradeAllowed(
_qualityProfile,
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatOne },
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatTwo })
.Should().BeTrue();
}
[Test]
public void should_return_true_for_same_custom_format_score_when_upgrading_is_not_allowed()
{
_qualityProfile.UpgradeAllowed = false;
Subject.IsUpgradeAllowed(
_qualityProfile,
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatOne },
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatOne })
.Should().BeTrue();
}
[Test]
public void should_return_true_for_lower_custom_format_score_when_upgrading_is_allowed()
{
_qualityProfile.UpgradeAllowed = true;
Subject.IsUpgradeAllowed(
_qualityProfile,
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatTwo },
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatOne })
.Should().BeTrue();
}
[Test]
public void should_return_true_for_lower_language_when_upgrading_is_not_allowed()
{
_qualityProfile.UpgradeAllowed = false;
Subject.IsUpgradeAllowed(
_qualityProfile,
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatTwo },
new QualityModel(Quality.MP3),
new List<CustomFormat> { _customFormatOne })
.Should().BeTrue();
}
[Test] [Test]
public void should_return_true_for_quality_upgrade_when_upgrading_is_allowed() public void should_return_true_for_quality_upgrade_when_upgrading_is_allowed()
{ {
_qualityProfile.UpgradeAllowed = true;
Subject.IsUpgradeAllowed( Subject.IsUpgradeAllowed(
new QualityProfile _qualityProfile,
{
Cutoff = Quality.FLAC.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
},
new QualityModel(Quality.MP3), new QualityModel(Quality.MP3),
new QualityModel(Quality.FLAC)) new List<CustomFormat>(),
new QualityModel(Quality.FLAC),
new List<CustomFormat>())
.Should().BeTrue(); .Should().BeTrue();
} }
[Test] [Test]
public void should_return_true_for_same_quality_when_upgrading_is_allowed() public void should_return_true_for_same_quality_when_upgrading_is_allowed()
{ {
_qualityProfile.UpgradeAllowed = true;
Subject.IsUpgradeAllowed( Subject.IsUpgradeAllowed(
new QualityProfile _qualityProfile,
{ new QualityModel(Quality.MP3),
Cutoff = Quality.FLAC.Id, new List<CustomFormat>(),
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
},
new QualityModel(Quality.MP3), new QualityModel(Quality.MP3),
new QualityModel(Quality.MP3)) new List<CustomFormat>())
.Should().BeTrue(); .Should().BeTrue();
} }
[Test] [Test]
public void should_return_true_for_same_quality_when_upgrading_is_not_allowed() public void should_return_true_for_same_quality_when_upgrading_is_not_allowed()
{ {
_qualityProfile.UpgradeAllowed = false;
Subject.IsUpgradeAllowed( Subject.IsUpgradeAllowed(
new QualityProfile _qualityProfile,
{
Cutoff = Quality.FLAC.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = false
},
new QualityModel(Quality.MP3), new QualityModel(Quality.MP3),
new QualityModel(Quality.MP3)) new List<CustomFormat>(),
new QualityModel(Quality.MP3),
new List<CustomFormat>())
.Should().BeTrue(); .Should().BeTrue();
} }
[Test] [Test]
public void should_return_true_for_lower_quality_when_upgrading_is_allowed() public void should_return_true_for_lower_quality_when_upgrading_is_allowed()
{ {
_qualityProfile.UpgradeAllowed = true;
Subject.IsUpgradeAllowed( Subject.IsUpgradeAllowed(
new QualityProfile _qualityProfile,
{
Cutoff = Quality.FLAC.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = true
},
new QualityModel(Quality.MP3), new QualityModel(Quality.MP3),
new QualityModel(Quality.MP3)) new List<CustomFormat>(),
new QualityModel(Quality.PDF),
new List<CustomFormat>())
.Should().BeTrue(); .Should().BeTrue();
} }
[Test] [Test]
public void should_return_true_for_lower_quality_when_upgrading_is_not_allowed() public void should_return_true_for_lower_quality_when_upgrading_is_not_allowed()
{ {
_qualityProfile.UpgradeAllowed = false;
Subject.IsUpgradeAllowed( Subject.IsUpgradeAllowed(
new QualityProfile _qualityProfile,
{
Cutoff = Quality.FLAC.Id,
Items = Qualities.QualityFixture.GetDefaultQualities(),
UpgradeAllowed = false
},
new QualityModel(Quality.MP3), new QualityModel(Quality.MP3),
new QualityModel(Quality.MP3)) new List<CustomFormat>(),
new QualityModel(Quality.PDF),
new List<CustomFormat>())
.Should().BeTrue(); .Should().BeTrue();
} }
} }

@ -6,11 +6,14 @@ using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Core.Books; using NzbDrone.Core.Books;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
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;
namespace NzbDrone.Core.Test.DecisionEngineTests namespace NzbDrone.Core.Test.DecisionEngineTests
@ -29,6 +32,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
Mocker.Resolve<UpgradableSpecification>(); Mocker.Resolve<UpgradableSpecification>();
CustomFormatsTestHelpers.GivenCustomFormats();
_firstFile = new BookFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 2)), DateAdded = DateTime.Now }; _firstFile = new BookFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 2)), DateAdded = DateTime.Now };
_secondFile = new BookFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 2)), DateAdded = DateTime.Now }; _secondFile = new BookFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 2)), DateAdded = DateTime.Now };
@ -40,7 +45,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
UpgradeAllowed = true, UpgradeAllowed = true,
Cutoff = Quality.MP3.Id, Cutoff = Quality.MP3.Id,
Items = Qualities.QualityFixture.GetDefaultQualities() Items = Qualities.QualityFixture.GetDefaultQualities(),
FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems("None"),
MinFormatScore = 0,
}) })
.Build(); .Build();
@ -52,15 +59,21 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
{ {
Author = fakeAuthor, Author = fakeAuthor,
ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) }, ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) },
Books = doubleBookList Books = doubleBookList,
CustomFormats = new List<CustomFormat>()
}; };
_parseResultSingle = new RemoteBook _parseResultSingle = new RemoteBook
{ {
Author = fakeAuthor, Author = fakeAuthor,
ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) }, ParsedBookInfo = new ParsedBookInfo { Quality = new QualityModel(Quality.MP3, new Revision(version: 2)) },
Books = singleBookList Books = singleBookList,
CustomFormats = new List<CustomFormat>()
}; };
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(x => x.ParseCustomFormat(It.IsAny<BookFile>()))
.Returns(new List<CustomFormat>());
} }
private void WithFirstFileUpgradable() private void WithFirstFileUpgradable()
@ -136,6 +149,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
[Test] [Test]
public void should_be_false_if_some_tracks_are_upgradable_and_some_are_downgrades() public void should_be_false_if_some_tracks_are_upgradable_and_some_are_downgrades()
{ {
Mocker.GetMock<ICustomFormatCalculationService>()
.Setup(s => s.ParseCustomFormat(It.IsAny<BookFile>()))
.Returns(new List<CustomFormat>());
WithFirstFileUpgradable(); WithFirstFileUpgradable();
_parseResultSingle.ParsedBookInfo.Quality = new QualityModel(Quality.MP3); _parseResultSingle.ParsedBookInfo.Quality = new QualityModel(Quality.MP3);
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse();

@ -2,6 +2,7 @@ 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.Profiles.Qualities; using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
@ -23,8 +24,6 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new object[] { Quality.MP3, 1, Quality.MP3, 1, Quality.MP3, false } new object[] { Quality.MP3, 1, Quality.MP3, 1, Quality.MP3, false }
}; };
private static readonly int NoPreferredWordScore = 0;
private void GivenAutoDownloadPropers(ProperDownloadTypes type) private void GivenAutoDownloadPropers(ProperDownloadTypes type)
{ {
Mocker.GetMock<IConfigService>() Mocker.GetMock<IConfigService>()
@ -47,9 +46,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsUpgradable( Subject.IsUpgradable(
profile, profile,
new QualityModel(current, new Revision(version: currentVersion)), new QualityModel(current, new Revision(version: currentVersion)),
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(newQuality, new Revision(version: newVersion)), new QualityModel(newQuality, new Revision(version: newVersion)),
NoPreferredWordScore) new List<CustomFormat>())
.Should().Be(expected); .Should().Be(expected);
} }
@ -66,9 +65,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsUpgradable( Subject.IsUpgradable(
profile, profile,
new QualityModel(Quality.MP3, new Revision(version: 1)), new QualityModel(Quality.MP3, new Revision(version: 1)),
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.MP3, new Revision(version: 2)), new QualityModel(Quality.MP3, new Revision(version: 2)),
NoPreferredWordScore) new List<CustomFormat>())
.Should().BeTrue(); .Should().BeTrue();
} }
@ -85,9 +84,9 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsUpgradable( Subject.IsUpgradable(
profile, profile,
new QualityModel(Quality.MP3, new Revision(version: 1)), new QualityModel(Quality.MP3, new Revision(version: 1)),
NoPreferredWordScore, new List<CustomFormat>(),
new QualityModel(Quality.MP3, new Revision(version: 2)), new QualityModel(Quality.MP3, new Revision(version: 2)),
NoPreferredWordScore) new List<CustomFormat>())
.Should().BeFalse(); .Should().BeFalse();
} }
} }

@ -104,6 +104,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
.With(h => h.Title = title) .With(h => h.Title = title)
.With(h => h.Release = release) .With(h => h.Release = release)
.With(h => h.Reason = reason) .With(h => h.Reason = reason)
.With(h => h.ParsedBookInfo = _parsedBookInfo)
.Build(); .Build();
_heldReleases.AddRange(heldReleases); _heldReleases.AddRange(heldReleases);

@ -52,7 +52,9 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
_pending.Add(new PendingRelease _pending.Add(new PendingRelease
{ {
Id = id, Id = id,
ParsedBookInfo = new ParsedBookInfo { BookTitle = book } Title = "Author.Title-Book.Title.abc-Readarr",
ParsedBookInfo = new ParsedBookInfo { BookTitle = book },
Release = Builder<ReleaseInfo>.CreateNew().Build()
}); });
} }

@ -0,0 +1,134 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Housekeeping.Housekeepers;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Housekeeping.Housekeepers
{
[TestFixture]
public class CleanupQualityProfileFormatItemsFixture : DbTest<CleanupQualityProfileFormatItems, QualityProfile>
{
[SetUp]
public void Setup()
{
Mocker.SetConstant<IQualityProfileFormatItemsCleanupRepository>(
new QualityProfileFormatItemsCleanupRepository(Mocker.Resolve<IMainDatabase>(), Mocker.Resolve<IEventAggregator>()));
Mocker.SetConstant<ICustomFormatRepository>(
new CustomFormatRepository(Mocker.Resolve<IMainDatabase>(), Mocker.Resolve<IEventAggregator>()));
}
[Test]
public void should_remove_orphaned_custom_formats()
{
var qualityProfile = Builder<QualityProfile>.CreateNew()
.With(h => h.Items = Qualities.QualityFixture.GetDefaultQualities())
.With(h => h.MinFormatScore = 50)
.With(h => h.CutoffFormatScore = 100)
.With(h => h.FormatItems = new List<ProfileFormatItem>
{
Builder<ProfileFormatItem>.CreateNew()
.With(c => c.Format = new CustomFormat("My Custom Format") { Id = 0 })
.Build()
})
.BuildNew();
Db.Insert(qualityProfile);
Subject.Clean();
var result = AllStoredModels;
result.Should().HaveCount(1);
result.First().FormatItems.Should().BeEmpty();
result.First().MinFormatScore.Should().Be(0);
result.First().CutoffFormatScore.Should().Be(0);
}
[Test]
public void should_not_remove_unorphaned_custom_formats()
{
var minFormatScore = 50;
var cutoffFormatScore = 100;
var customFormat = Builder<CustomFormat>.CreateNew()
.With(h => h.Specifications = new List<ICustomFormatSpecification>())
.BuildNew();
Db.Insert(customFormat);
var qualityProfile = Builder<QualityProfile>.CreateNew()
.With(h => h.Items = Qualities.QualityFixture.GetDefaultQualities())
.With(h => h.MinFormatScore = minFormatScore)
.With(h => h.CutoffFormatScore = cutoffFormatScore)
.With(h => h.FormatItems = new List<ProfileFormatItem>
{
Builder<ProfileFormatItem>.CreateNew()
.With(c => c.Format = customFormat)
.Build()
})
.BuildNew();
Db.Insert(qualityProfile);
Subject.Clean();
var result = AllStoredModels;
result.Should().HaveCount(1);
result.First().FormatItems.Should().HaveCount(1);
result.First().MinFormatScore.Should().Be(minFormatScore);
result.First().CutoffFormatScore.Should().Be(cutoffFormatScore);
}
[Test]
public void should_add_missing_custom_formats()
{
var minFormatScore = 50;
var cutoffFormatScore = 100;
var customFormat1 = Builder<CustomFormat>.CreateNew()
.With(h => h.Id = 1)
.With(h => h.Name = "Custom Format 1")
.With(h => h.Specifications = new List<ICustomFormatSpecification>())
.BuildNew();
var customFormat2 = Builder<CustomFormat>.CreateNew()
.With(h => h.Id = 2)
.With(h => h.Name = "Custom Format 2")
.With(h => h.Specifications = new List<ICustomFormatSpecification>())
.BuildNew();
Db.Insert(customFormat1);
Db.Insert(customFormat2);
var qualityProfile = Builder<QualityProfile>.CreateNew()
.With(h => h.Items = Qualities.QualityFixture.GetDefaultQualities())
.With(h => h.MinFormatScore = minFormatScore)
.With(h => h.CutoffFormatScore = cutoffFormatScore)
.With(h => h.FormatItems = new List<ProfileFormatItem>
{
Builder<ProfileFormatItem>.CreateNew()
.With(c => c.Format = customFormat1)
.Build()
})
.BuildNew();
Db.Insert(qualityProfile);
Subject.Clean();
var result = AllStoredModels;
result.Should().HaveCount(1);
result.First().FormatItems.Should().HaveCount(2);
result.First().MinFormatScore.Should().Be(minFormatScore);
result.First().CutoffFormatScore.Should().Be(cutoffFormatScore);
}
}
}

@ -0,0 +1,142 @@
using System.IO;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Books;
using NzbDrone.Core.MediaFiles.BookImport;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MediaFiles.BookImport
{
[TestFixture]
public class GetSceneNameFixture : CoreTest
{
private LocalBook _localEpisode;
private string _seasonName = "artist.title-album.title.FLAC-ingot";
private string _episodeName = "artist.title-album.title.FLAC-ingot";
[SetUp]
public void Setup()
{
var series = Builder<Author>.CreateNew()
.With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() })
.With(s => s.Path = @"C:\Test\Music\Artist Title".AsOsAgnostic())
.Build();
var episode = Builder<Book>.CreateNew()
.Build();
_localEpisode = new LocalBook
{
Author = series,
Book = episode,
Path = Path.Combine(series.Path, "01 Some Body Loves.mkv"),
Quality = new QualityModel(Quality.FLAC),
ReleaseGroup = "DRONE"
};
}
[Test]
public void should_use_download_client_item_title_as_scene_name()
{
_localEpisode.DownloadClientBookInfo = new ParsedBookInfo
{
ReleaseTitle = _episodeName
};
SceneNameCalculator.GetSceneName(_localEpisode).Should()
.Be(_episodeName);
}
[Test]
public void should_not_use_download_client_item_title_as_scene_name_if_full_season()
{
_localEpisode.DownloadClientBookInfo = new ParsedBookInfo
{
ReleaseTitle = _seasonName,
Discography = true
};
_localEpisode.Path = Path.Combine(@"C:\Test\Unsorted TV", _seasonName, _episodeName)
.AsOsAgnostic();
SceneNameCalculator.GetSceneName(_localEpisode).Should()
.BeNull();
}
[Test]
public void should_not_use_file_name_as_scenename_if_it_doesnt_look_like_scenename()
{
_localEpisode.Path = Path.Combine(@"C:\Test\Unsorted TV", _episodeName, "aaaaa.mkv")
.AsOsAgnostic();
SceneNameCalculator.GetSceneName(_localEpisode).Should()
.BeNull();
}
[Test]
public void should_not_use_folder_name_as_scenename_if_it_doesnt_look_like_scenename()
{
_localEpisode.Path = Path.Combine(@"C:\Test\Unsorted TV", _episodeName, "aaaaa.mkv")
.AsOsAgnostic();
_localEpisode.FolderTrackInfo = new ParsedBookInfo
{
ReleaseTitle = "aaaaa"
};
SceneNameCalculator.GetSceneName(_localEpisode).Should()
.BeNull();
}
[Test]
public void should_not_use_folder_name_as_scenename_if_it_is_for_a_full_season()
{
_localEpisode.Path = Path.Combine(@"C:\Test\Unsorted TV", _episodeName, "aaaaa.mkv")
.AsOsAgnostic();
_localEpisode.FolderTrackInfo = new ParsedBookInfo
{
ReleaseTitle = _seasonName,
Discography = true
};
SceneNameCalculator.GetSceneName(_localEpisode).Should()
.BeNull();
}
[Test]
public void should_not_use_folder_name_as_scenename_if_there_are_other_video_files()
{
_localEpisode.Path = Path.Combine(@"C:\Test\Unsorted TV", _episodeName, "aaaaa.mkv")
.AsOsAgnostic();
_localEpisode.FolderTrackInfo = new ParsedBookInfo
{
ReleaseTitle = _seasonName,
Discography = false
};
SceneNameCalculator.GetSceneName(_localEpisode).Should()
.BeNull();
}
[TestCase(".flac")]
[TestCase(".par2")]
[TestCase(".nzb")]
public void should_remove_extension_from_nzb_title_for_scene_name(string extension)
{
_localEpisode.DownloadClientBookInfo = new ParsedBookInfo
{
ReleaseTitle = _episodeName + extension
};
SceneNameCalculator.GetSceneName(_localEpisode).Should()
.Be(_episodeName);
}
}
}

@ -1,106 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Books;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.BookImport.Specifications;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.MediaFiles.BookImport.Specifications
{
[TestFixture]
public class SameFileSpecificationFixture : CoreTest<SameFileSpecification>
{
private LocalBook _localTrack;
[SetUp]
public void Setup()
{
_localTrack = Builder<LocalBook>.CreateNew()
.With(l => l.Size = 150.Megabytes())
.Build();
}
[Test]
public void should_be_accepted_if_no_existing_file()
{
_localTrack.Book = Builder<Book>.CreateNew()
.Build();
Subject.IsSatisfiedBy(_localTrack, null).Accepted.Should().BeTrue();
}
/*
[Test]
public void should_be_accepted_if_multiple_existing_files()
{
_localTrack.Tracks = Builder<Track>.CreateListOfSize(2)
.TheFirst(1)
.With(e => e.TrackFileId = 1)
.With(e => e.TrackFile = new LazyLoaded<TrackFile>(
new TrackFile
{
Size = _localTrack.Size
}))
.TheNext(1)
.With(e => e.TrackFileId = 2)
.With(e => e.TrackFile = new LazyLoaded<TrackFile>(
new TrackFile
{
Size = _localTrack.Size
}))
.Build()
.ToList();
Subject.IsSatisfiedBy(_localTrack, null).Accepted.Should().BeTrue();
}*/
[Test]
public void should_be_accepted_if_file_size_is_different()
{
_localTrack.Book = Builder<Book>.CreateNew()
.With(e => e.BookFiles = new LazyLoaded<List<BookFile>>(
new List<BookFile>
{
new BookFile
{
Size = _localTrack.Size + 100.Megabytes()
}
}))
.Build();
Subject.IsSatisfiedBy(_localTrack, null).Accepted.Should().BeTrue();
}
[Test]
public void should_be_reject_if_file_size_is_the_same()
{
_localTrack.Book = Builder<Book>.CreateNew()
.With(e => e.BookFiles = new LazyLoaded<List<BookFile>>(
new List<BookFile>
{
new BookFile
{
Size = _localTrack.Size
}
}))
.Build();
Subject.IsSatisfiedBy(_localTrack, null).Accepted.Should().BeFalse();
}
[Test]
public void should_be_accepted_if_file_cannot_be_fetched()
{
_localTrack.Book = Builder<Book>.CreateNew()
.With(e => e.BookFiles = new LazyLoaded<List<BookFile>>((List<BookFile>)null))
.Build();
Subject.IsSatisfiedBy(_localTrack, null).Accepted.Should().BeTrue();
}
}
}

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

Loading…
Cancel
Save