New: Custom Formats

Co-Authored-By: ta264 <ta264@users.noreply.github.com>
pull/5116/head
Qstick 2 years ago committed by Mark McDowall
parent 909af6c874
commit b04b4000b8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -3,17 +3,20 @@ import React, { Component } from 'react';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeLanguage from 'Episode/EpisodeLanguage';
import EpisodeNumber from 'Episode/EpisodeNumber';
import EpisodeQuality from 'Episode/EpisodeQuality';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import styles from './SeriesHistoryRow.css';
function getTitle(eventType) {
@ -68,6 +71,7 @@ class SeriesHistoryRow extends Component {
languageCutoffNotMet,
quality,
qualityCutoffNotMet,
customFormats,
date,
data,
fullSeries,
@ -142,6 +146,28 @@ class SeriesHistoryRow extends Component {
/>
</TableRowCell>
<TableRowCell className={styles.customFormatScore}>
<Tooltip
anchor={
formatPreferredWordScore(data.customFormatScore)
}
tooltip={
<div>
{
customFormats.map((format) => {
return (
<Label key={format.id}>
{format.name}
</Label>
);
})
}
</div>
}
position={tooltipPositions.BOTTOM}
/>
</TableRowCell>
<TableRowCell className={styles.actions}>
{
eventType === 'grabbed' &&
@ -175,6 +201,7 @@ SeriesHistoryRow.propTypes = {
languageCutoffNotMet: PropTypes.bool.isRequired,
quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
fullSeries: 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,38 @@
.customFormat {
composes: card from '~Components/Card.css';
width: 300px;
}
.nameContainer {
display: flex;
justify-content: space-between;
}
.name {
@add-mixin truncate;
margin-bottom: 20px;
font-weight: 300;
font-size: 24px;
}
.cloneButton {
composes: button from '~Components/Link/IconButton.css';
height: 36px;
}
.formats {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
pointer-events: all;
}
.tooltipLabel {
composes: label from '~Components/Label.css';
margin: 0;
border: none;
}

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

@ -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>
{'Sonarr supports custom conditions against the release properties below.'}
</div>
<div>
{'Visit the wiki for more details: '}
<Link to="https://wiki.servarr.com/sonarr/settings#custom-formats-2">{'Wiki'}</Link>
</div>
</Alert>
<div className={styles.specifications}>
{
schema.map((specification) => {
return (
<AddSpecificationItem
key={specification.implementation}
{...specification}
onSpecificationSelect={onSpecificationSelect}
/>
);
})
}
</div>
</div>
}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
AddSpecificationModalContent.propTypes = {
isSchemaFetching: PropTypes.bool.isRequired,
isSchemaPopulated: PropTypes.bool.isRequired,
schemaError: PropTypes.object,
schema: PropTypes.arrayOf(PropTypes.object).isRequired,
onSpecificationSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddSpecificationModalContent;

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

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

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

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

@ -14,11 +14,23 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import QualityProfileFormatItems from './QualityProfileFormatItems';
import QualityProfileItems from './QualityProfileItems';
import styles from './EditQualityProfileModalContent.css';
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 {
//
@ -92,6 +104,7 @@ class EditQualityProfileModalContent extends Component {
isSaving,
saveError,
qualities,
customFormats,
item,
isInUse,
onInputChange,
@ -107,7 +120,10 @@ class EditQualityProfileModalContent extends Component {
name,
upgradeAllowed,
cutoff,
items
minFormatScore,
cutoffFormatScore,
items,
formatItems
} = item;
return (
@ -189,6 +205,44 @@ class EditQualityProfileModalContent extends Component {
/>
</FormGroup>
}
{
formatItems.value.length > 0 &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Minimum Custom Format Score
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minFormatScore"
{...minFormatScore}
helpText="Minimum custom format score allowed to download"
onChange={onInputChange}
/>
</FormGroup>
}
{
upgradeAllowed.value && formatItems.value.length > 0 &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Upgrade Until Custom Format Score
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="cutoffFormatScore"
{...cutoffFormatScore}
helpText="Once this custom format score is reached Sonarr will no longer grab episode releases"
onChange={onInputChange}
/>
</FormGroup>
}
<div className={styles.formatItemLarge}>
{getCustomFormatRender(formatItems, ...otherProps)}
</div>
</div>
<div className={styles.formGroupWrapper}>
@ -200,6 +254,10 @@ class EditQualityProfileModalContent extends Component {
{...otherProps}
/>
</div>
<div className={styles.formatItemSmall}>
{getCustomFormatRender(formatItems, otherProps)}
</div>
</div>
</Form>
@ -215,7 +273,7 @@ class EditQualityProfileModalContent extends Component {
>
<ModalFooter>
{
id &&
id ?
<div
className={styles.deleteButtonContainer}
title={
@ -231,7 +289,8 @@ class EditQualityProfileModalContent extends Component {
>
Delete
</Button>
</div>
</div> :
null
}
<Button
@ -261,6 +320,7 @@ EditQualityProfileModalContent.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
item: PropTypes.object.isRequired,
isInUse: PropTypes.bool.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() {
return createSelector(
createProviderSettingsSelector('qualityProfiles'),
createQualitiesSelector(),
createFormatsSelector(),
createProfileInUseSelector('qualityProfileId'),
(qualityProfile, qualities, isInUse) => {
(qualityProfile, qualities, customFormats, isInUse) => {
return {
qualities,
customFormats,
...qualityProfile,
isInUse
};
@ -178,6 +210,19 @@ class EditQualityProfileModalContentConnector extends Component {
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) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
@ -420,6 +465,7 @@ class EditQualityProfileModalContentConnector extends Component {
onItemGroupNameChange={this.onItemGroupNameChange}
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onQualityProfileFormatItemScoreChange={this.onQualityProfileFormatItemScoreChange}
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="Sonarr scores each release using the sum of scores for matching custom formats. If a new release would improve the score, at the same or better quality, then Sonarr will grab it."
/>
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={false}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={false}
/>
);
})
}
<div className={styles.formats}>
<div className={styles.headerContainer}>
<div className={styles.headerTitle}>
Custom Format
</div>
<div className={styles.headerScore}>
Score
</div>
</div>
{
order.map((index) => {
const {
format,
name,
score
} = profileFormatItems[index];
return (
<QualityProfileFormatItem
key={format}
formatId={format}
name={name}
score={score}
onScoreChange={this.onScoreChange}
/>
);
})
}
</div>
</div>
</FormGroup>
);
}
}
QualityProfileFormatItems.propTypes = {
profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),
onQualityProfileFormatItemScoreChange: PropTypes.func
};
QualityProfileFormatItems.defaultProps = {
errors: [],
warnings: []
};
export default QualityProfileFormatItems;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save