New: Custom Formats

Co-Authored-By: ta264 <ta264@users.noreply.github.com>
pull/3310/head
Qstick 2 years ago
parent 86e44731bb
commit 9fe13a2d14

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

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AlbumFormats from 'Album/AlbumFormats';
import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
import IconButton from 'Components/Link/IconButton';
@ -45,6 +46,7 @@ class BlocklistRow extends Component {
artist,
sourceTitle,
quality,
customFormats,
date,
protocol,
indexer,
@ -110,6 +112,16 @@ class BlocklistRow extends Component {
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<AlbumFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
@ -174,6 +186,7 @@ BlocklistRow.propTypes = {
artist: PropTypes.object.isRequired,
sourceTitle: PropTypes.string.isRequired,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object).isRequired,
date: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,

@ -9,6 +9,7 @@ import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import translate from 'Utilities/String/translate';
import styles from './HistoryDetails.css';
@ -67,6 +68,7 @@ function HistoryDetails(props) {
const {
indexer,
releaseGroup,
customFormatScore,
nzbInfoUrl,
downloadClient,
downloadClientName,
@ -105,7 +107,16 @@ function HistoryDetails(props) {
}
{
!!nzbInfoUrl &&
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title="Custom Format Score"
data={formatPreferredWordScore(customFormatScore)}
/> :
null
}
{
nzbInfoUrl ?
<span>
<DescriptionListItemTitle>
Info URL
@ -114,7 +125,8 @@ function HistoryDetails(props) {
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span>
</span> :
null
}
{
@ -179,6 +191,7 @@ function HistoryDetails(props) {
if (eventType === 'trackFileImported') {
const {
customFormatScore,
droppedPath,
importedPath
} = data;
@ -201,12 +214,22 @@ function HistoryDetails(props) {
}
{
!!importedPath &&
importedPath ?
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ImportedTo')}
data={importedPath}
/>
/> :
null
}
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title="Custom Format Score"
data={formatPreferredWordScore(customFormatScore)}
/> :
null
}
</DescriptionList>
);
@ -214,7 +237,8 @@ function HistoryDetails(props) {
if (eventType === 'trackFileDeleted') {
const {
reason
reason,
customFormatScore
} = data;
let reasonMessage = '';
@ -244,6 +268,15 @@ function HistoryDetails(props) {
title={translate('Reason')}
data={reasonMessage}
/>
{
customFormatScore && customFormatScore !== '0' ?
<DescriptionListItem
title="Custom Format Score"
data={formatPreferredWordScore(customFormatScore)}
/> :
null
}
</DescriptionList>
);
}

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

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AlbumFormats from 'Album/AlbumFormats';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
@ -8,6 +9,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore';
import HistoryDetailsModal from './Details/HistoryDetailsModal';
import HistoryEventTypeCell from './HistoryEventTypeCell';
import styles from './HistoryRow.css';
@ -55,6 +57,7 @@ class HistoryRow extends Component {
album,
track,
quality,
customFormats,
qualityCutoffNotMet,
eventType,
sourceTitle,
@ -136,6 +139,16 @@ class HistoryRow extends Component {
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<AlbumFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
@ -167,6 +180,17 @@ class HistoryRow extends Component {
);
}
if (name === 'customFormatScore') {
return (
<TableRowCell
key={name}
className={styles.customFormatScore}
>
{formatPreferredWordScore(data.customFormatScore)}
</TableRowCell>
);
}
if (name === 'releaseGroup') {
return (
<TableRowCell
@ -229,6 +253,7 @@ HistoryRow.propTypes = {
album: PropTypes.object,
track: PropTypes.object,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
qualityCutoffNotMet: PropTypes.bool.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import AlbumFormats from 'Album/AlbumFormats';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
@ -89,6 +90,7 @@ class QueueRow extends Component {
artist,
album,
quality,
customFormats,
protocol,
indexer,
outputPath,
@ -214,6 +216,16 @@ class QueueRow extends Component {
);
}
if (name === 'customFormats') {
return (
<TableRowCell key={name}>
<AlbumFormats
formats={customFormats}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
@ -382,6 +394,7 @@ QueueRow.propTypes = {
artist: PropTypes.object,
album: PropTypes.object,
quality: PropTypes.object.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,
outputPath: PropTypes.string,

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

@ -13,6 +13,7 @@ import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import AddNewItemConnector from 'Search/AddNewItemConnector';
import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
@ -167,6 +168,11 @@ function AppRoutes(props) {
component={QualityConnector}
/>
<Route
path="/settings/customformats"
component={CustomFormatSettingsConnector}
/>
<Route
path="/settings/indexers"
component={IndexerSettingsConnector}

@ -27,6 +27,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';
@ -100,6 +101,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;

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

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

@ -104,6 +104,10 @@ const links = [
title: translate('Quality'),
to: '/settings/quality'
},
{
title: translate('CustomFormats'),
to: '/settings/customformats'
},
{
title: translate('Indexers'),
to: '/settings/indexers'

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

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

@ -22,6 +22,7 @@ export const DYNAMIC_SELECT = 'dynamicSelect';
export const TAG = 'tag';
export const TAG_SELECT = 'tagSelect';
export const TEXT = 'text';
export const TEXT_AREA = 'textArea';
export const TEXT_TAG = 'textTag';
export const UMASK = 'umask';
@ -50,6 +51,7 @@ export const all = [
TAG,
TAG_SELECT,
TEXT,
TEXT_AREA,
TEXT_TAG,
UMASK
];

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

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

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AlbumFormats from 'Album/AlbumFormats';
import TrackQuality from 'Album/TrackQuality';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -169,6 +170,7 @@ class InteractiveImportRow extends Component {
quality,
releaseGroup,
size,
customFormats,
rejections,
isReprocessing,
audioTags,
@ -303,7 +305,26 @@ class InteractiveImportRow extends Component {
<TableRowCell>
{
rejections && rejections.length ?
customFormats?.length ?
<Popover
anchor={
<Icon name={icons.INTERACTIVE} />
}
title="Formats"
body={
<div className={styles.customFormatTooltip}>
<AlbumFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/> :
null
}
</TableRowCell>
<TableRowCell>
{
rejections.length ?
<Popover
anchor={
<Icon
@ -391,6 +412,7 @@ InteractiveImportRow.propTypes = {
releaseGroup: PropTypes.string,
quality: PropTypes.object,
size: PropTypes.number.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
audioTags: PropTypes.object.isRequired,
additionalFile: PropTypes.bool.isRequired,

@ -56,10 +56,10 @@ const columns = [
isVisible: true
},
{
name: 'preferredWordScore',
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: translate('PreferredWordScore')
title: translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,101 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import AddSpecificationItem from './AddSpecificationItem';
import styles from './AddSpecificationModalContent.css';
class AddSpecificationModalContent extends Component {
//
// Render
render() {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema,
onSpecificationSelect,
onModalClose
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add Condition
</ModalHeader>
<ModalBody>
{
isSchemaFetching &&
<LoadingIndicator />
}
{
!isSchemaFetching && !!schemaError &&
<div>
{'Unable to add a new condition, please try again.'}
</div>
}
{
isSchemaPopulated && !schemaError &&
<div>
<Alert kind={kinds.INFO}>
<div>
{'Lidarr supports custom conditions against the release properties below.'}
</div>
<div>
{'Visit the wiki for more details: '}
<Link to="https://wiki.servarr.com/lidarr/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;

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

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

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

@ -3,7 +3,8 @@
flex-wrap: wrap;
}
.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;
}
}

@ -15,11 +15,23 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import translate from 'Utilities/String/translate';
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 {
//
@ -93,6 +105,7 @@ class EditQualityProfileModalContent extends Component {
isSaving,
saveError,
qualities,
customFormats,
item,
isInUse,
onInputChange,
@ -108,7 +121,10 @@ class EditQualityProfileModalContent extends Component {
name,
upgradeAllowed,
cutoff,
items
minFormatScore,
cutoffFormatScore,
items,
formatItems
} = item;
return (
@ -190,6 +206,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 Lidarr will no longer grab album releases"
onChange={onInputChange}
/>
</FormGroup>
}
<div className={styles.formatItemLarge}>
{getCustomFormatRender(formatItems, ...otherProps)}
</div>
</div>
<div className={styles.formGroupWrapper}>
@ -201,6 +255,10 @@ class EditQualityProfileModalContent extends Component {
{...otherProps}
/>
</div>
<div className={styles.formatItemSmall}>
{getCustomFormatRender(formatItems, otherProps)}
</div>
</div>
</Form>
@ -216,7 +274,7 @@ class EditQualityProfileModalContent extends Component {
>
<ModalFooter>
{
id &&
id ?
<div
className={styles.deleteButtonContainer}
title={isInUse ? translate('IsInUseCantDeleteAQualityProfileThatIsAttachedToAnArtistOrImportList') : undefined}
@ -228,7 +286,8 @@ class EditQualityProfileModalContent extends Component {
>
{translate('Delete')}
</Button>
</div>
</div> :
null
}
<Button
@ -258,6 +317,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="Lidarr 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 LIdarr will grab it."
/>
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={false}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={false}
/>
);
})
}
<div className={styles.formats}>
<div className={styles.headerContainer}>
<div className={styles.headerTitle}>
Custom Format
</div>
<div className={styles.headerScore}>
Score
</div>
</div>
{
order.map((index) => {
const {
format,
name,
score
} = profileFormatItems[index];
return (
<QualityProfileFormatItem
key={format}
formatId={format}
name={name}
score={score}
onScoreChange={this.onScoreChange}
/>
);
})
}
</div>
</div>
</FormGroup>
);
}
}
QualityProfileFormatItems.propTypes = {
profileFormatItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),
onQualityProfileFormatItemScoreChange: PropTypes.func
};
QualityProfileFormatItems.defaultProps = {
errors: [],
warnings: []
};
export default QualityProfileFormatItems;

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

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

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

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

@ -47,6 +47,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);
}
}
};

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

@ -1,5 +1,7 @@
import React from 'react';
import { createAction } from 'redux-actions';
import { filterTypes, sortDirections } from 'Helpers/Props';
import Icon from 'Components/Icon';
import { filterTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
@ -56,6 +58,12 @@ export const defaultState = {
label: translate('Quality'),
isVisible: true
},
{
name: 'customFormats',
label: 'Formats',
isSortable: false,
isVisible: true
},
{
name: 'date',
label: translate('Date'),
@ -82,6 +90,20 @@ export const defaultState = {
label: translate('SourceTitle'),
isVisible: false
},
{
name: 'sourceTitle',
label: 'Source Title',
isVisible: false
},
{
name: 'customFormatScore',
columnLabel: 'Custom Format Score',
label: React.createElement(Icon, {
name: icons.SCORE,
title: 'Custom format score'
}),
isVisible: false
},
{
name: 'details',
columnLabel: translate('Details'),

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

@ -197,6 +197,11 @@ export const defaultState = {
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'customFormatScore',
label: translate('CustomFormatScore'),
type: filterBuilderTypes.NUMBER
},
{
name: 'rejectionCount',
label: translate('RejectionCount'),

@ -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';
@ -24,6 +26,8 @@ import remotePathMappings from './Settings/remotePathMappings';
import rootFolders from './Settings/rootFolders';
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';
@ -58,6 +62,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,
@ -100,6 +106,8 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
// Action Handlers
export const actionHandlers = handleThunks({
...customFormatSpecifications.actionHandlers,
...customFormats.actionHandlers,
...delayProfiles.actionHandlers,
...downloadClients.actionHandlers,
...downloadClientOptions.actionHandlers,
@ -133,6 +141,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,16 @@
function formatPreferredWordScore(input, customFormatsLength = 0) {
const score = Number(input);
if (score > 0) {
return `+${score}`;
}
if (score < 0) {
return score;
}
return customFormatsLength > 0 ? '+0' : '';
}
export default formatPreferredWordScore;

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

@ -1,7 +1,7 @@
import _ from 'lodash';
import 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;
}, []);

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

@ -3,6 +3,7 @@ using Lidarr.Http.Extensions;
using Lidarr.Http.REST.Attributes;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore;
namespace Lidarr.Api.V1.Blocklist
@ -11,10 +12,13 @@ namespace Lidarr.Api.V1.Blocklist
public class BlocklistController : Controller
{
private readonly IBlocklistService _blocklistService;
private readonly ICustomFormatCalculationService _formatCalculator;
public BlocklistController(IBlocklistService blocklistService)
public BlocklistController(IBlocklistService blocklistService,
ICustomFormatCalculationService formatCalculator)
{
_blocklistService = blocklistService;
_formatCalculator = formatCalculator;
}
[HttpGet]
@ -23,7 +27,7 @@ namespace Lidarr.Api.V1.Blocklist
var pagingResource = Request.ReadPagingResourceFromRequest<BlocklistResource>();
var pagingSpec = pagingResource.MapToPagingSpec<BlocklistResource, NzbDrone.Core.Blocklisting.Blocklist>("date", SortDirection.Descending);
return pagingSpec.ApplyToPage(_blocklistService.Paged, BlocklistResourceMapper.MapToResource);
return pagingSpec.ApplyToPage(_blocklistService.Paged, model => BlocklistResourceMapper.MapToResource(model, _formatCalculator));
}
[RestDeleteById]

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.CustomFormats;
using Lidarr.Http.REST;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Qualities;
@ -13,6 +15,7 @@ namespace Lidarr.Api.V1.Blocklist
public List<int> AlbumIds { get; set; }
public string SourceTitle { get; set; }
public QualityModel Quality { get; set; }
public List<CustomFormatResource> CustomFormats { get; set; }
public DateTime Date { get; set; }
public DownloadProtocol Protocol { get; set; }
public string Indexer { get; set; }
@ -23,7 +26,7 @@ namespace Lidarr.Api.V1.Blocklist
public static class BlocklistResourceMapper
{
public static BlocklistResource MapToResource(this NzbDrone.Core.Blocklisting.Blocklist model)
public static BlocklistResource MapToResource(this NzbDrone.Core.Blocklisting.Blocklist model, ICustomFormatCalculationService formatCalculator)
{
if (model == null)
{
@ -38,6 +41,7 @@ namespace Lidarr.Api.V1.Blocklist
AlbumIds = model.AlbumIds,
SourceTitle = model.SourceTitle,
Quality = model.Quality,
CustomFormats = formatCalculator.ParseCustomFormat(model, model.Artist).ToResource(false),
Date = model.Date,
Protocol = model.Protocol,
Indexer = model.Indexer,

@ -0,0 +1,115 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using Lidarr.Api.V1.CustomFormats;
using Lidarr.Http;
using Lidarr.Http.REST;
using Lidarr.Http.REST.Attributes;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
namespace Sonarr.Api.V3.CustomFormats
{
[V1ApiController]
public class CustomFormatController : RestController<CustomFormatResource>
{
private readonly ICustomFormatService _formatService;
private readonly List<ICustomFormatSpecification> _specifications;
public CustomFormatController(ICustomFormatService formatService,
List<ICustomFormatSpecification> specifications)
{
_formatService = formatService;
_specifications = specifications;
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Name)
.Must((v, c) => !_formatService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique.");
SharedValidator.RuleFor(c => c.Specifications).NotEmpty();
SharedValidator.RuleFor(c => c).Custom((customFormat, context) =>
{
if (!customFormat.Specifications.Any())
{
context.AddFailure("Must contain at least one Condition");
}
if (customFormat.Specifications.Any(s => s.Name.IsNullOrWhiteSpace()))
{
context.AddFailure("Condition name(s) cannot be empty or consist of only spaces");
}
});
}
public override CustomFormatResource GetResourceById(int id)
{
return _formatService.GetById(id).ToResource(true);
}
[RestPostById]
[Consumes("application/json")]
public ActionResult<CustomFormatResource> Create(CustomFormatResource customFormatResource)
{
var model = customFormatResource.ToModel(_specifications);
return Created(_formatService.Insert(model).Id);
}
[RestPutById]
[Consumes("application/json")]
public ActionResult<CustomFormatResource> Update(CustomFormatResource resource)
{
var model = resource.ToModel(_specifications);
_formatService.Update(model);
return Accepted(model.Id);
}
[HttpGet]
[Produces("application/json")]
public List<CustomFormatResource> GetAll()
{
return _formatService.All().ToResource(true);
}
[RestDeleteById]
public void DeleteFormat(int id)
{
_formatService.Delete(id);
}
[HttpGet("schema")]
public object GetTemplates()
{
var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList();
var presets = GetPresets();
foreach (var item in schema)
{
item.Presets = presets.Where(x => x.GetType().Name == item.Implementation).Select(x => x.ToSchema()).ToList();
}
return schema;
}
private IEnumerable<ICustomFormatSpecification> GetPresets()
{
yield return new ReleaseTitleSpecification
{
Name = "Preferred Words",
Value = @"\b(SPARKS|Framestor)\b"
};
var formats = _formatService.All();
foreach (var format in formats)
{
foreach (var condition in format.Specifications)
{
var preset = condition.Clone();
preset.Name = $"{format.Name}: {preset.Name}";
yield return preset;
}
}
}
}
}

@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Lidarr.Http.ClientSchema;
using Lidarr.Http.REST;
using NzbDrone.Core.CustomFormats;
namespace Lidarr.Api.V1.CustomFormats
{
public class CustomFormatResource : RestResource
{
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public override int Id { get; set; }
public string Name { get; set; }
public bool? IncludeCustomFormatWhenRenaming { get; set; }
public List<CustomFormatSpecificationSchema> Specifications { get; set; }
}
public static class CustomFormatResourceMapper
{
public static CustomFormatResource ToResource(this CustomFormat model, bool includeDetails)
{
var resource = new CustomFormatResource
{
Id = model.Id,
Name = model.Name
};
if (includeDetails)
{
resource.IncludeCustomFormatWhenRenaming = model.IncludeCustomFormatWhenRenaming;
resource.Specifications = model.Specifications.Select(x => x.ToSchema()).ToList();
}
return resource;
}
public static List<CustomFormatResource> ToResource(this IEnumerable<CustomFormat> models, bool includeDetails)
{
return models.Select(m => m.ToResource(includeDetails)).ToList();
}
public static CustomFormat ToModel(this CustomFormatResource resource, List<ICustomFormatSpecification> specifications)
{
return new CustomFormat
{
Id = resource.Id,
Name = resource.Name,
IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? false,
Specifications = resource.Specifications?.Select(x => MapSpecification(x, specifications)).ToList() ?? new List<ICustomFormatSpecification>()
};
}
private static ICustomFormatSpecification MapSpecification(CustomFormatSpecificationSchema resource, List<ICustomFormatSpecification> specifications)
{
var matchingSpec =
specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation);
if (matchingSpec is null)
{
throw new ArgumentException(
$"{resource.Implementation} is not a valid specification implementation");
}
var type = matchingSpec.GetType();
var spec = (ICustomFormatSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type);
spec.Name = resource.Name;
spec.Negate = resource.Negate;
spec.Required = resource.Required;
return spec;
}
}
}

@ -0,0 +1,36 @@
using System.Collections.Generic;
using Lidarr.Http.ClientSchema;
using Lidarr.Http.REST;
using NzbDrone.Core.CustomFormats;
namespace Lidarr.Api.V1.CustomFormats
{
public class CustomFormatSpecificationSchema : RestResource
{
public string Name { get; set; }
public string Implementation { get; set; }
public string ImplementationName { get; set; }
public string InfoLink { get; set; }
public bool Negate { get; set; }
public bool Required { get; set; }
public List<Field> Fields { get; set; }
public List<CustomFormatSpecificationSchema> Presets { get; set; }
}
public static class CustomFormatSpecificationSchemaMapper
{
public static CustomFormatSpecificationSchema ToSchema(this ICustomFormatSpecification model)
{
return new CustomFormatSpecificationSchema
{
Name = model.Name,
Implementation = model.GetType().Name,
ImplementationName = model.ImplementationName,
InfoLink = model.InfoLink,
Negate = model.Negate,
Required = model.Required,
Fields = SchemaBuilder.ToSchema(model)
};
}
}
}

@ -7,6 +7,7 @@ using Lidarr.Api.V1.Tracks;
using Lidarr.Http;
using Lidarr.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Download;
@ -18,21 +19,24 @@ namespace Lidarr.Api.V1.History
public class HistoryController : Controller
{
private readonly IHistoryService _historyService;
private readonly ICustomFormatCalculationService _formatCalculator;
private readonly IUpgradableSpecification _upgradableSpecification;
private readonly IFailedDownloadService _failedDownloadService;
public HistoryController(IHistoryService historyService,
ICustomFormatCalculationService formatCalculator,
IUpgradableSpecification upgradableSpecification,
IFailedDownloadService failedDownloadService)
{
_historyService = historyService;
_formatCalculator = formatCalculator;
_upgradableSpecification = upgradableSpecification;
_failedDownloadService = failedDownloadService;
}
protected HistoryResource MapToResource(EntityHistory model, bool includeArtist, bool includeAlbum, bool includeTrack)
{
var resource = model.ToResource();
var resource = model.ToResource(_formatCalculator);
if (includeArtist)
{

@ -2,8 +2,10 @@ using System;
using System.Collections.Generic;
using Lidarr.Api.V1.Albums;
using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.CustomFormats;
using Lidarr.Api.V1.Tracks;
using Lidarr.Http.REST;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.History;
using NzbDrone.Core.Qualities;
@ -16,6 +18,7 @@ namespace Lidarr.Api.V1.History
public int TrackId { get; set; }
public string SourceTitle { get; set; }
public QualityModel Quality { get; set; }
public List<CustomFormatResource> CustomFormats { get; set; }
public bool QualityCutoffNotMet { get; set; }
public DateTime Date { get; set; }
public string DownloadId { get; set; }
@ -31,7 +34,7 @@ namespace Lidarr.Api.V1.History
public static class HistoryResourceMapper
{
public static HistoryResource ToResource(this EntityHistory model)
public static HistoryResource ToResource(this EntityHistory model, ICustomFormatCalculationService formatCalculator)
{
if (model == null)
{
@ -47,6 +50,7 @@ namespace Lidarr.Api.V1.History
TrackId = model.TrackId,
SourceTitle = model.SourceTitle,
Quality = model.Quality,
CustomFormats = formatCalculator.ParseCustomFormat(model, model.Artist).ToResource(false),
// QualityCutoffNotMet
Date = model.Date,

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Lidarr.Api.V1.CustomFormats;
using Lidarr.Http.REST;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Indexers;
@ -40,7 +41,8 @@ namespace Lidarr.Api.V1.Indexers
public string InfoUrl { get; set; }
public bool DownloadAllowed { get; set; }
public int ReleaseWeight { get; set; }
public int PreferredWordScore { get; set; }
public List<CustomFormatResource> CustomFormats { get; set; }
public int CustomFormatScore { get; set; }
public string MagnetUrl { get; set; }
public string InfoHash { get; set; }
@ -99,7 +101,8 @@ namespace Lidarr.Api.V1.Indexers
DownloadAllowed = remoteAlbum.DownloadAllowed,
// ReleaseWeight
PreferredWordScore = remoteAlbum.PreferredWordScore,
CustomFormatScore = remoteAlbum.CustomFormatScore,
CustomFormats = remoteAlbum.CustomFormats.ToResource(false),
MagnetUrl = torrentInfo.MagnetUrl,
InfoHash = torrentInfo.InfoHash,

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using Lidarr.Http.REST;
using NzbDrone.Core.Indexers;
@ -13,6 +13,9 @@ namespace Lidarr.Api.V1.Profiles.Delay
public DownloadProtocol PreferredProtocol { get; set; }
public int UsenetDelay { get; set; }
public int TorrentDelay { get; set; }
public bool BypassIfHighestQuality { get; set; }
public bool BypassIfAboveCustomFormatScore { get; set; }
public int MinimumCustomFormatScore { get; set; }
public int Order { get; set; }
public HashSet<int> Tags { get; set; }
}
@ -35,6 +38,9 @@ namespace Lidarr.Api.V1.Profiles.Delay
PreferredProtocol = model.PreferredProtocol,
UsenetDelay = model.UsenetDelay,
TorrentDelay = model.TorrentDelay,
BypassIfHighestQuality = model.BypassIfHighestQuality,
BypassIfAboveCustomFormatScore = model.BypassIfAboveCustomFormatScore,
MinimumCustomFormatScore = model.MinimumCustomFormatScore,
Order = model.Order,
Tags = new HashSet<int>(model.Tags)
};
@ -56,6 +62,9 @@ namespace Lidarr.Api.V1.Profiles.Delay
PreferredProtocol = resource.PreferredProtocol,
UsenetDelay = resource.UsenetDelay,
TorrentDelay = resource.TorrentDelay,
BypassIfHighestQuality = resource.BypassIfHighestQuality,
BypassIfAboveCustomFormatScore = resource.BypassIfAboveCustomFormatScore,
MinimumCustomFormatScore = resource.MinimumCustomFormatScore,
Order = resource.Order,
Tags = new HashSet<int>(resource.Tags)
};

@ -1,9 +1,12 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using Lidarr.Http;
using Lidarr.Http.REST;
using Lidarr.Http.REST.Attributes;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Profiles.Qualities;
namespace Lidarr.Api.V1.Profiles.Quality
@ -12,13 +15,30 @@ namespace Lidarr.Api.V1.Profiles.Quality
public class QualityProfileController : RestController<QualityProfileResource>
{
private readonly IQualityProfileService _qualityProfileService;
private readonly ICustomFormatService _formatService;
public QualityProfileController(IQualityProfileService qualityProfileService)
public QualityProfileController(IQualityProfileService qualityProfileService, ICustomFormatService formatService)
{
_qualityProfileService = qualityProfileService;
_formatService = formatService;
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Cutoff).ValidCutoff();
SharedValidator.RuleFor(c => c.Items).ValidItems();
SharedValidator.RuleFor(c => c.FormatItems).Must(items =>
{
var all = _formatService.All().Select(f => f.Id).ToList();
var ids = items.Select(i => i.Format);
return all.Except(ids).Empty();
}).WithMessage("All Custom Formats and no extra ones need to be present inside your Profile! Try refreshing your browser.");
SharedValidator.RuleFor(c => c).Custom((profile, context) =>
{
if (profile.FormatItems.Where(x => x.Score > 0).Sum(x => x.Score) < profile.MinFormatScore &&
profile.FormatItems.Max(x => x.Score) < profile.MinFormatScore)
{
context.AddFailure("Minimum Custom Format Score can never be satisfied");
}
});
}
[RestPostById]

@ -1,6 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using Lidarr.Http.REST;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Profiles;
using NzbDrone.Core.Profiles.Qualities;
namespace Lidarr.Api.V1.Profiles.Quality
@ -11,6 +13,9 @@ namespace Lidarr.Api.V1.Profiles.Quality
public bool UpgradeAllowed { get; set; }
public int Cutoff { get; set; }
public List<QualityProfileQualityItemResource> Items { get; set; }
public int MinFormatScore { get; set; }
public int CutoffFormatScore { get; set; }
public List<ProfileFormatItemResource> FormatItems { get; set; }
}
public class QualityProfileQualityItemResource : RestResource
@ -26,6 +31,13 @@ namespace Lidarr.Api.V1.Profiles.Quality
}
}
public class ProfileFormatItemResource : RestResource
{
public int Format { get; set; }
public string Name { get; set; }
public int Score { get; set; }
}
public static class ProfileResourceMapper
{
public static QualityProfileResource ToResource(this QualityProfile model)
@ -41,7 +53,10 @@ namespace Lidarr.Api.V1.Profiles.Quality
Name = model.Name,
UpgradeAllowed = model.UpgradeAllowed,
Cutoff = model.Cutoff,
Items = model.Items.ConvertAll(ToResource)
Items = model.Items.ConvertAll(ToResource),
MinFormatScore = model.MinFormatScore,
CutoffFormatScore = model.CutoffFormatScore,
FormatItems = model.FormatItems.ConvertAll(ToResource)
};
}
@ -62,6 +77,16 @@ namespace Lidarr.Api.V1.Profiles.Quality
};
}
public static ProfileFormatItemResource ToResource(this ProfileFormatItem model)
{
return new ProfileFormatItemResource
{
Format = model.Format.Id,
Name = model.Format.Name,
Score = model.Score
};
}
public static QualityProfile ToModel(this QualityProfileResource resource)
{
if (resource == null)
@ -75,7 +100,10 @@ namespace Lidarr.Api.V1.Profiles.Quality
Name = resource.Name,
UpgradeAllowed = resource.UpgradeAllowed,
Cutoff = resource.Cutoff,
Items = resource.Items.ConvertAll(ToModel)
Items = resource.Items.ConvertAll(ToModel),
MinFormatScore = resource.MinFormatScore,
CutoffFormatScore = resource.CutoffFormatScore,
FormatItems = resource.FormatItems.ConvertAll(ToModel)
};
}
@ -96,6 +124,15 @@ namespace Lidarr.Api.V1.Profiles.Quality
};
}
public static ProfileFormatItem ToModel(this ProfileFormatItemResource resource)
{
return new ProfileFormatItem
{
Format = new CustomFormat { Id = resource.Format },
Score = resource.Score
};
}
public static List<QualityProfileResource> ToResource(this IEnumerable<QualityProfile> models)
{
return models.Select(ToResource).ToList();

@ -24,7 +24,7 @@ namespace Lidarr.Api.V1.Profiles.Release
SharedValidator.RuleFor(r => r).Custom((restriction, context) =>
{
if (restriction.Ignored.IsNullOrWhiteSpace() && restriction.Required.IsNullOrWhiteSpace() && restriction.Preferred.Empty())
if (restriction.Ignored.Empty() && restriction.Required.Empty())
{
context.AddFailure("Either 'Must contain' or 'Must not contain' is required");
}
@ -33,11 +33,6 @@ namespace Lidarr.Api.V1.Profiles.Release
{
context.AddFailure(nameof(ReleaseProfile.IndexerId), "Indexer does not exist");
}
if (restriction.Preferred.Any(p => p.Key.IsNullOrWhiteSpace()))
{
context.AddFailure("Preferred", "Term cannot be empty or consist of only spaces");
}
});
}

@ -8,10 +8,8 @@ namespace Lidarr.Api.V1.Profiles.Release
public class ReleaseProfileResource : RestResource
{
public bool Enabled { get; set; }
public string Required { get; set; }
public string Ignored { get; set; }
public List<KeyValuePair<string, int>> Preferred { get; set; }
public bool IncludePreferredWhenRenaming { get; set; }
public List<string> Required { get; set; }
public List<string> Ignored { get; set; }
public int IndexerId { get; set; }
public HashSet<int> Tags { get; set; }
@ -37,8 +35,6 @@ namespace Lidarr.Api.V1.Profiles.Release
Enabled = model.Enabled,
Required = model.Required,
Ignored = model.Ignored,
Preferred = model.Preferred,
IncludePreferredWhenRenaming = model.IncludePreferredWhenRenaming,
IndexerId = model.IndexerId,
Tags = new HashSet<int>(model.Tags)
};
@ -58,8 +54,6 @@ namespace Lidarr.Api.V1.Profiles.Release
Enabled = resource.Enabled,
Required = resource.Required,
Ignored = resource.Ignored,
Preferred = resource.Preferred,
IncludePreferredWhenRenaming = resource.IncludePreferredWhenRenaming,
IndexerId = resource.IndexerId,
Tags = new HashSet<int>(resource.Tags)
};

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Lidarr.Api.V1.Albums;
using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.CustomFormats;
using Lidarr.Http.REST;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Download.TrackedDownloads;
@ -18,6 +19,7 @@ namespace Lidarr.Api.V1.Queue
public ArtistResource Artist { get; set; }
public AlbumResource Album { get; set; }
public QualityModel Quality { get; set; }
public List<CustomFormatResource> CustomFormats { get; set; }
public decimal Size { get; set; }
public string Title { get; set; }
public decimal Sizeleft { get; set; }
@ -53,6 +55,7 @@ namespace Lidarr.Api.V1.Queue
Artist = includeArtist && model.Artist != null ? model.Artist.ToResource() : null,
Album = includeAlbum && model.Album != null ? model.Album.ToResource() : null,
Quality = model.Quality,
CustomFormats = model.RemoteAlbum?.CustomFormats?.ToResource(false),
Size = model.Size,
Title = model.Title,
Sizeleft = model.Sizeleft,

@ -1,4 +1,5 @@
using System.Collections.Generic;
using NzbDrone.Core.Annotations;
namespace Lidarr.Http.ClientSchema
{

@ -5,7 +5,7 @@ namespace Lidarr.Http.REST
public abstract class RestResource
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Id { get; set; }
public virtual int Id { get; set; }
[JsonIgnore]
public virtual string ResourceName => GetType().Name.ToLowerInvariant().Replace("resource", "");

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

@ -0,0 +1,431 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class add_custom_formatsFixture : MigrationTest<add_custom_formats>
{
[Test]
public void should_add_cf_from_named_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<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_not_migrate_if_bad_regex_in_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "[somestring[",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<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
{
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("Unnamed_1");
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeTrue();
customFormats.First().Specifications.Should().HaveCount(1);
}
[Test]
public void should_not_remove_release_profile_if_ignored_or_required()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "some",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var releaseProfiles = db.Query<ReleaseProfile171>("SELECT \"Id\" FROM \"ReleaseProfiles\"");
releaseProfiles.Should().HaveCount(1);
}
[Test]
public void should_remove_release_profile_if_no_ignored_or_required()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = true,
Enabled = true,
IndexerId = 0
});
});
var releaseProfiles = db.Query<ReleaseProfile171>("SELECT \"Id\" FROM \"ReleaseProfiles\"");
releaseProfiles.Should().HaveCount(0);
}
[Test]
public void should_add_cf_from_unnamed_release_profile()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<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_two_cfs_if_release_profile_has_multiple_terms()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
},
new
{
Key = "x265",
Value = 5
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
});
var customFormats = db.Query<CustomFormat171>("SELECT \"Id\", \"Name\", \"IncludeCustomFormatWhenRenaming\", \"Specifications\" FROM \"CustomFormats\"");
customFormats.Should().HaveCount(2);
customFormats.First().Name.Should().Be("Unnamed_1_0");
customFormats.Last().Name.Should().Be("Unnamed_1_1");
customFormats.First().IncludeCustomFormatWhenRenaming.Should().BeFalse();
customFormats.First().Specifications.Should().HaveCount(1);
}
[Test]
public void should_set_scores_for_enabled_release_profiles()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("ReleaseProfiles").Row(new
{
Preferred = new[]
{
new
{
Key = "x264",
Value = 2
}
}.ToJson(),
Required = "",
Ignored = "",
Tags = "[]",
IncludePreferredWhenRenaming = false,
Enabled = true,
IndexerId = 0
});
c.Insert.IntoTable("QualityProfiles").Row(new
{
Name = "SDTV",
Cutoff = 1,
Items = "[ { \"quality\": 1, \"allowed\": true } ]"
});
});
var customFormats = db.Query<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
{
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
{
ReplaceIllegalCharacters = false,
StandardTrackFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Preferred Words } {Quality Full}",
MultiDiscTrackFormat = "{Series Title} - {Air-Date} - {Episode Title} {Preferred.Words } {Quality Full}",
});
});
var customFormats = db.Query<NamingConfig171>("SELECT \"StandardTrackFormat\", \"MultiDiscTrackFormat\" FROM \"NamingConfig\"");
customFormats.Should().HaveCount(1);
customFormats.First().StandardTrackFormat.Should().Be("{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Custom Formats } {Quality Full}");
customFormats.First().MultiDiscTrackFormat.Should().Be("{Series Title} - {Air-Date} - {Episode Title} {Custom.Formats } {Quality Full}");
}
private class NamingConfig171
{
public string StandardTrackFormat { get; set; }
public string MultiDiscTrackFormat { get; set; }
}
private class ReleaseProfile171
{
public int Id { 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; }
}
}
}

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

Loading…
Cancel
Save