mirror of https://github.com/lidarr/Lidarr
New: Custom Formats
Co-Authored-By: ta264 <ta264@users.noreply.github.com>
This commit is contained in:
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…
Reference in New Issue