mirror of
https://github.com/Radarr/Radarr.git
synced 2025-04-23 22:17:15 -04:00
New: Dynamic Select and UMask Fields
Fixes #5380 Fixes #5348 Fixes #5167 Fixes #5166 Co-Authored-By: Taloth <Taloth@users.noreply.github.com>
This commit is contained in:
parent
73ce77f1ca
commit
9c77399379
50 changed files with 1244 additions and 212 deletions
|
@ -2,34 +2,22 @@ import PropTypes from 'prop-types';
|
|||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearOptions, fetchOptions } from 'Store/Actions/providerOptionActions';
|
||||
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
|
||||
import DeviceInput from './DeviceInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { value }) => value,
|
||||
(state, { name }) => name,
|
||||
(state) => state.providerOptions,
|
||||
(value, name, devices) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = devices;
|
||||
(state) => state.providerOptions.devices || defaultState,
|
||||
(value, devices) => {
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items: items[name] || [],
|
||||
...devices,
|
||||
selectedDevices: value.map((valueDevice) => {
|
||||
const sectionItems = items[name] || [];
|
||||
|
||||
// Disable equality ESLint rule so we don't need to worry about
|
||||
// a type mismatch between the value items and the device ID.
|
||||
// eslint-disable-next-line eqeqeq
|
||||
const device = sectionItems.find((d) => d.id == valueDevice);
|
||||
const device = devices.items.find((d) => d.id == valueDevice);
|
||||
|
||||
if (device) {
|
||||
return {
|
||||
|
@ -63,7 +51,7 @@ class DeviceInputConnector extends Component {
|
|||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
this.props.dispatchClearOptions();
|
||||
this.props.dispatchClearOptions({ section: 'devices' });
|
||||
}
|
||||
|
||||
//
|
||||
|
@ -73,14 +61,12 @@ class DeviceInputConnector extends Component {
|
|||
const {
|
||||
provider,
|
||||
providerData,
|
||||
dispatchFetchOptions,
|
||||
requestAction,
|
||||
name
|
||||
dispatchFetchOptions
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchOptions({
|
||||
action: requestAction,
|
||||
itemSection: name,
|
||||
section: 'devices',
|
||||
action: 'getDevices',
|
||||
provider,
|
||||
providerData
|
||||
});
|
||||
|
@ -109,7 +95,6 @@ class DeviceInputConnector extends Component {
|
|||
DeviceInputConnector.propTypes = {
|
||||
provider: PropTypes.string.isRequired,
|
||||
providerData: PropTypes.object.isRequired,
|
||||
requestAction: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
dispatchFetchOptions: PropTypes.func.isRequired,
|
||||
|
|
|
@ -5,6 +5,10 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.editableContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from '~Components/Form/Input.css';
|
||||
}
|
||||
|
@ -22,6 +26,16 @@
|
|||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.dropdownArrowContainerEditable {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding-right: 17px;
|
||||
width: 30%;
|
||||
height: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dropdownArrowContainerDisabled {
|
||||
composes: dropdownArrowContainer;
|
||||
|
||||
|
@ -66,3 +80,8 @@
|
|||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
margin: 5px -5px 5px 0;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import React, { Component } from 'react';
|
|||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Measure from 'Components/Measure';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
|
@ -16,6 +17,7 @@ import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
|||
import { isMobile as isMobileUtil } from 'Utilities/mobile';
|
||||
import HintedSelectInputOption from './HintedSelectInputOption';
|
||||
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
function isArrowKey(keyCode) {
|
||||
|
@ -168,11 +170,21 @@ class EnhancedSelectInput extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
if (this.state.isOpen) {
|
||||
this._removeListener();
|
||||
this.setState({ isOpen: false });
|
||||
}
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
|
||||
const origIndex = getSelectedIndex(this.props);
|
||||
if (origIndex !== this.state.selectedIndex) {
|
||||
this.setState({ selectedIndex: origIndex });
|
||||
if (!this.props.isEditable) {
|
||||
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
|
||||
const origIndex = getSelectedIndex(this.props);
|
||||
|
||||
if (origIndex !== this.state.selectedIndex) {
|
||||
this.setState({ selectedIndex: origIndex });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -250,6 +262,10 @@ class EnhancedSelectInput extends Component {
|
|||
this._addListener();
|
||||
}
|
||||
|
||||
if (!this.state.isOpen && this.props.onOpen) {
|
||||
this.props.onOpen();
|
||||
}
|
||||
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
}
|
||||
|
||||
|
@ -292,15 +308,19 @@ class EnhancedSelectInput extends Component {
|
|||
const {
|
||||
className,
|
||||
disabledClassName,
|
||||
name,
|
||||
value,
|
||||
values,
|
||||
isDisabled,
|
||||
isEditable,
|
||||
isFetching,
|
||||
hasError,
|
||||
hasWarning,
|
||||
valueOptions,
|
||||
selectedValueOptions,
|
||||
selectedValueComponent: SelectedValueComponent,
|
||||
optionComponent: OptionComponent
|
||||
optionComponent: OptionComponent,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
|
@ -326,40 +346,94 @@ class EnhancedSelectInput extends Component {
|
|||
whitelist={['width']}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
value={value}
|
||||
values={values}
|
||||
{...selectedValueOptions}
|
||||
{...selectedOption}
|
||||
isDisabled={isDisabled}
|
||||
isMultiSelect={isMultiSelect}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : null}
|
||||
</SelectedValueComponent>
|
||||
{
|
||||
isEditable ?
|
||||
<div
|
||||
className={styles.editableContainer}
|
||||
>
|
||||
<TextInput
|
||||
className={className}
|
||||
name={name}
|
||||
value={value}
|
||||
readOnly={isDisabled}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.dropdownArrowContainerEditable,
|
||||
isDisabled ?
|
||||
styles.dropdownArrowContainerDisabled :
|
||||
styles.dropdownArrowContainer)
|
||||
}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
|
||||
<div
|
||||
className={isDisabled ?
|
||||
styles.dropdownArrowContainerDisabled :
|
||||
styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
{
|
||||
!isFetching &&
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
}
|
||||
</Link>
|
||||
</div> :
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
value={value}
|
||||
values={values}
|
||||
{...selectedValueOptions}
|
||||
{...selectedOption}
|
||||
isDisabled={isDisabled}
|
||||
isMultiSelect={isMultiSelect}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : null}
|
||||
</SelectedValueComponent>
|
||||
|
||||
<div
|
||||
className={isDisabled ?
|
||||
styles.dropdownArrowContainerDisabled :
|
||||
styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching &&
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</Link>
|
||||
}
|
||||
</Measure>
|
||||
</div>
|
||||
)}
|
||||
|
@ -483,12 +557,15 @@ EnhancedSelectInput.propTypes = {
|
|||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
valueOptions: PropTypes.object.isRequired,
|
||||
selectedValueOptions: PropTypes.object.isRequired,
|
||||
selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
|
||||
optionComponent: PropTypes.elementType,
|
||||
onOpen: PropTypes.func,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
@ -496,6 +573,8 @@ EnhancedSelectInput.defaultProps = {
|
|||
className: styles.enhancedSelect,
|
||||
disabledClassName: styles.isDisabled,
|
||||
isDisabled: false,
|
||||
isFetching: false,
|
||||
isEditable: false,
|
||||
valueOptions: {},
|
||||
selectedValueOptions: {},
|
||||
selectedValueComponent: HintedSelectInputSelectedValue,
|
||||
|
|
159
frontend/src/Components/Form/EnhancedSelectInputConnector.js
Normal file
159
frontend/src/Components/Form/EnhancedSelectInputConnector.js
Normal file
|
@ -0,0 +1,159 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
const importantFieldNames = [
|
||||
'baseUrl',
|
||||
'apiPath',
|
||||
'apiKey'
|
||||
];
|
||||
|
||||
function getProviderDataKey(providerData) {
|
||||
if (!providerData || !providerData.fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fields = providerData.fields
|
||||
.filter((f) => importantFieldNames.includes(f.name))
|
||||
.map((f) => f.value);
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getSelectOptions(items) {
|
||||
if (!items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map((option) => {
|
||||
return {
|
||||
key: option.value,
|
||||
value: option.name,
|
||||
hint: option.hint,
|
||||
parentKey: option.parentValue
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState,
|
||||
(options) => {
|
||||
if (options) {
|
||||
return {
|
||||
isFetching: options.isFetching,
|
||||
values: getSelectOptions(options.items)
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchOptions: fetchOptions,
|
||||
dispatchClearOptions: clearOptions
|
||||
};
|
||||
|
||||
class EnhancedSelectInputConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
refetchRequired: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this._populate();
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const prevKey = getProviderDataKey(prevProps.providerData);
|
||||
const nextKey = getProviderDataKey(this.props.providerData);
|
||||
|
||||
if (!_.isEqual(prevKey, nextKey)) {
|
||||
this.setState({ refetchRequired: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
this._cleanup();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onOpen = () => {
|
||||
if (this.state.refetchRequired) {
|
||||
this._populate();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_populate() {
|
||||
const {
|
||||
provider,
|
||||
providerData,
|
||||
selectOptionsProviderAction,
|
||||
dispatchFetchOptions
|
||||
} = this.props;
|
||||
|
||||
if (selectOptionsProviderAction) {
|
||||
this.setState({ refetchRequired: false });
|
||||
dispatchFetchOptions({
|
||||
section: selectOptionsProviderAction,
|
||||
action: selectOptionsProviderAction,
|
||||
provider,
|
||||
providerData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
const {
|
||||
selectOptionsProviderAction,
|
||||
dispatchClearOptions
|
||||
} = this.props;
|
||||
|
||||
if (selectOptionsProviderAction) {
|
||||
dispatchClearOptions({ section: selectOptionsProviderAction });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
onOpen={this.onOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EnhancedSelectInputConnector.propTypes = {
|
||||
provider: PropTypes.string.isRequired,
|
||||
providerData: PropTypes.object.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
|
||||
values: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectOptionsProviderAction: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
dispatchFetchOptions: PropTypes.func.isRequired,
|
||||
dispatchClearOptions: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector);
|
|
@ -32,6 +32,7 @@ class EnhancedSelectInputOption extends Component {
|
|||
const {
|
||||
className,
|
||||
id,
|
||||
depth,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
isHidden,
|
||||
|
@ -54,6 +55,11 @@ class EnhancedSelectInputOption extends Component {
|
|||
onPress={this.onPress}
|
||||
>
|
||||
|
||||
{
|
||||
depth !== 0 &&
|
||||
<div style={{ width: `${depth * 20}px` }} />
|
||||
}
|
||||
|
||||
{
|
||||
isMultiSelect &&
|
||||
<CheckInput
|
||||
|
@ -84,6 +90,7 @@ class EnhancedSelectInputOption extends Component {
|
|||
EnhancedSelectInputOption.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
depth: PropTypes.number.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
isHidden: PropTypes.bool.isRequired,
|
||||
|
@ -95,6 +102,7 @@ EnhancedSelectInputOption.propTypes = {
|
|||
|
||||
EnhancedSelectInputOption.defaultProps = {
|
||||
className: styles.option,
|
||||
depth: 0,
|
||||
isDisabled: false,
|
||||
isHidden: false,
|
||||
isMultiSelect: false
|
||||
|
|
|
@ -9,6 +9,7 @@ import CaptchaInputConnector from './CaptchaInputConnector';
|
|||
import CheckInput from './CheckInput';
|
||||
import DeviceInputConnector from './DeviceInputConnector';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector';
|
||||
import KeyValueListInput from './KeyValueListInput';
|
||||
|
@ -24,6 +25,7 @@ import TagSelectInputConnector from './TagSelectInputConnector';
|
|||
import TextArea from './TextArea';
|
||||
import TextInput from './TextInput';
|
||||
import TextTagInputConnector from './TextTagInputConnector';
|
||||
import UMaskInput from './UMaskInput';
|
||||
import styles from './FormInputGroup.css';
|
||||
|
||||
function getComponent(type) {
|
||||
|
@ -73,6 +75,8 @@ function getComponent(type) {
|
|||
case inputTypes.SELECT:
|
||||
return EnhancedSelectInput;
|
||||
|
||||
case inputTypes.DYNAMIC_SELECT:
|
||||
return EnhancedSelectInputConnector;
|
||||
case inputTypes.TAG:
|
||||
return TagInputConnector;
|
||||
|
||||
|
@ -85,6 +89,9 @@ function getComponent(type) {
|
|||
case inputTypes.TAG_SELECT:
|
||||
return TagSelectInputConnector;
|
||||
|
||||
case inputTypes.UMASK:
|
||||
return UMaskInput;
|
||||
|
||||
default:
|
||||
return TextInput;
|
||||
}
|
||||
|
@ -192,7 +199,7 @@ function FormInputGroup(props) {
|
|||
}
|
||||
|
||||
{
|
||||
!checkInput && helpTextWarning &&
|
||||
(!checkInput || helpText) && helpTextWarning &&
|
||||
<FormInputHelpText
|
||||
text={helpTextWarning}
|
||||
isWarning={true}
|
||||
|
|
|
@ -9,6 +9,7 @@ function HintedSelectInputOption(props) {
|
|||
id,
|
||||
value,
|
||||
hint,
|
||||
depth,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
isMultiSelect,
|
||||
|
@ -19,6 +20,7 @@ function HintedSelectInputOption(props) {
|
|||
return (
|
||||
<EnhancedSelectInputOption
|
||||
id={id}
|
||||
depth={depth}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
isHidden={isDisabled}
|
||||
|
@ -48,6 +50,7 @@ HintedSelectInputOption.propTypes = {
|
|||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
hint: PropTypes.node,
|
||||
depth: PropTypes.number,
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
isMultiSelect: PropTypes.bool.isRequired,
|
||||
|
|
|
@ -98,7 +98,9 @@ class KeyValueListInput extends Component {
|
|||
className,
|
||||
value,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder
|
||||
valuePlaceholder,
|
||||
hasError,
|
||||
hasWarning
|
||||
} = this.props;
|
||||
|
||||
const { isFocused } = this.state;
|
||||
|
@ -106,7 +108,9 @@ class KeyValueListInput extends Component {
|
|||
return (
|
||||
<div className={classNames(
|
||||
className,
|
||||
isFocused && styles.isFocused
|
||||
isFocused && styles.isFocused,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning
|
||||
)}
|
||||
>
|
||||
{
|
||||
|
|
|
@ -6,7 +6,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
|
|||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
|
||||
function getType(type, value) {
|
||||
function getType({ type, selectOptionsProviderAction }) {
|
||||
switch (type) {
|
||||
case 'captcha':
|
||||
return inputTypes.CAPTCHA;
|
||||
|
@ -23,6 +23,9 @@ function getType(type, value) {
|
|||
case 'filePath':
|
||||
return inputTypes.PATH;
|
||||
case 'select':
|
||||
if (selectOptionsProviderAction) {
|
||||
return inputTypes.DYNAMIC_SELECT;
|
||||
}
|
||||
return inputTypes.SELECT;
|
||||
case 'tag':
|
||||
return inputTypes.TEXT_TAG;
|
||||
|
@ -63,7 +66,6 @@ function ProviderFieldFormGroup(props) {
|
|||
value,
|
||||
type,
|
||||
advanced,
|
||||
requestAction,
|
||||
hidden,
|
||||
pending,
|
||||
errors,
|
||||
|
@ -88,7 +90,7 @@ function ProviderFieldFormGroup(props) {
|
|||
<FormLabel>{label}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={getType(type, value)}
|
||||
type={getType(props)}
|
||||
name={name}
|
||||
label={label}
|
||||
helpText={helpText}
|
||||
|
@ -100,7 +102,6 @@ function ProviderFieldFormGroup(props) {
|
|||
pending={pending}
|
||||
includeFiles={type === 'filePath' ? true : undefined}
|
||||
onChange={onChange}
|
||||
requestAction={requestAction}
|
||||
{...otherProps}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
@ -109,7 +110,8 @@ function ProviderFieldFormGroup(props) {
|
|||
|
||||
const selectOptionsShape = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.number.isRequired
|
||||
value: PropTypes.number.isRequired,
|
||||
hint: PropTypes.string
|
||||
};
|
||||
|
||||
ProviderFieldFormGroup.propTypes = {
|
||||
|
@ -121,12 +123,12 @@ ProviderFieldFormGroup.propTypes = {
|
|||
value: PropTypes.any,
|
||||
type: PropTypes.string.isRequired,
|
||||
advanced: PropTypes.bool.isRequired,
|
||||
requestAction: PropTypes.string,
|
||||
hidden: PropTypes.string,
|
||||
pending: PropTypes.bool.isRequired,
|
||||
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
|
||||
selectOptionsProviderAction: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
|
53
frontend/src/Components/Form/UMaskInput.css
Normal file
53
frontend/src/Components/Form/UMaskInput.css
Normal file
|
@ -0,0 +1,53 @@
|
|||
.inputWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inputFolder {
|
||||
composes: input from '~Components/Form/Input.css';
|
||||
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.inputUnitWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputUnit {
|
||||
composes: inputUnit from '~Components/Form/FormInputGroup.css';
|
||||
|
||||
right: 40px;
|
||||
font-family: $monoSpaceFontFamily;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-family: $monoSpaceFontFamily;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-top: 5px;
|
||||
margin-left: 17px;
|
||||
line-height: 20px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
|
||||
label {
|
||||
flex: 0 0 50px;
|
||||
}
|
||||
|
||||
.value {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.unit {
|
||||
width: 90px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
background-color: #eee;
|
||||
}
|
133
frontend/src/Components/Form/UMaskInput.js
Normal file
133
frontend/src/Components/Form/UMaskInput.js
Normal file
|
@ -0,0 +1,133 @@
|
|||
/* eslint-disable no-bitwise */
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
import styles from './UMaskInput.css';
|
||||
|
||||
const umaskOptions = [
|
||||
{
|
||||
key: '755',
|
||||
value: '755 - Owner write, Everyone else read',
|
||||
hint: 'drwxr-xr-x'
|
||||
},
|
||||
{
|
||||
key: '775',
|
||||
value: '775 - Owner & Group write, Other read',
|
||||
hint: 'drwxrwxr-x'
|
||||
},
|
||||
{
|
||||
key: '770',
|
||||
value: '770 - Owner & Group write',
|
||||
hint: 'drwxrwx---'
|
||||
},
|
||||
{
|
||||
key: '750',
|
||||
value: '750 - Owner write, Group read',
|
||||
hint: 'drwxr-x---'
|
||||
},
|
||||
{
|
||||
key: '777',
|
||||
value: '777 - Everyone write',
|
||||
hint: 'drwxrwxrwx'
|
||||
}
|
||||
];
|
||||
|
||||
function formatPermissions(permissions) {
|
||||
|
||||
const hasSticky = permissions & 0o1000;
|
||||
const hasSetGID = permissions & 0o2000;
|
||||
const hasSetUID = permissions & 0o4000;
|
||||
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const bit = (permissions & (1 << i)) !== 0;
|
||||
let digit = bit ? 'xwr'[i % 3] : '-';
|
||||
if (i === 6 && hasSetUID) {
|
||||
digit = bit ? 's' : 'S';
|
||||
} else if (i === 3 && hasSetGID) {
|
||||
digit = bit ? 's' : 'S';
|
||||
} else if (i === 0 && hasSticky) {
|
||||
digit = bit ? 't' : 'T';
|
||||
}
|
||||
result = digit + result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class UMaskInput extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
const valueNum = parseInt(value, 8);
|
||||
const umaskNum = 0o777 & ~valueNum;
|
||||
const umask = umaskNum.toString(8).padStart(4, '0');
|
||||
const folderNum = 0o777 & ~umaskNum;
|
||||
const folder = folderNum.toString(8).padStart(3, '0');
|
||||
const fileNum = 0o666 & ~umaskNum;
|
||||
const file = fileNum.toString(8).padStart(3, '0');
|
||||
|
||||
const unit = formatPermissions(folderNum);
|
||||
|
||||
const values = umaskOptions.map((v) => {
|
||||
return { ...v, hint: <span className={styles.unit}>{v.hint}</span> };
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={styles.inputUnitWrapper}>
|
||||
<EnhancedSelectInput
|
||||
name={name}
|
||||
value={value}
|
||||
values={values}
|
||||
isEditable={true}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<div className={styles.inputUnit}>
|
||||
d{unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
<label>UMask</label>
|
||||
<div className={styles.value}>{umask}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Folder</label>
|
||||
<div className={styles.value}>{folder}</div>
|
||||
<div className={styles.unit}>d{formatPermissions(folderNum)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>File</label>
|
||||
<div className={styles.value}>{file}</div>
|
||||
<div className={styles.unit}>{formatPermissions(fileNum)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UMaskInput.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func
|
||||
};
|
||||
|
||||
export default UMaskInput;
|
|
@ -12,11 +12,13 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
|
|||
export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
|
||||
export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect';
|
||||
export const SELECT = 'select';
|
||||
export const DYNAMIC_SELECT = 'dynamicSelect';
|
||||
export const TAG = 'tag';
|
||||
export const TEXT = 'text';
|
||||
export const TEXT_AREA = 'textArea';
|
||||
export const TEXT_TAG = 'textTag';
|
||||
export const TAG_SELECT = 'tagSelect';
|
||||
export const UMASK = 'umask';
|
||||
|
||||
export const all = [
|
||||
AUTO_COMPLETE,
|
||||
|
@ -33,9 +35,11 @@ export const all = [
|
|||
ROOT_FOLDER_SELECT,
|
||||
INDEXER_FLAGS_SELECT,
|
||||
SELECT,
|
||||
DYNAMIC_SELECT,
|
||||
TAG,
|
||||
TEXT,
|
||||
TEXT_AREA,
|
||||
TEXT_TAG,
|
||||
TAG_SELECT
|
||||
TAG_SELECT,
|
||||
UMASK
|
||||
];
|
||||
|
|
|
@ -364,17 +364,32 @@ class MediaManagement extends Component {
|
|||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('FileChmodMode')}</FormLabel>
|
||||
<FormLabel>{translate('ChmodFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.UMASK}
|
||||
name="chmodFolder"
|
||||
helpText={translate('ChmodFolderHelpText')}
|
||||
helpTextWarning={translate('ChmodFolderHelpTextWarning')}
|
||||
onChange={onInputChange}
|
||||
{...settings.chmodFolder}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('ChmodGroup')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="fileChmod"
|
||||
helpTexts={[
|
||||
translate('FileChmodHelpTexts1'),
|
||||
translate('FileChmodHelpTexts2')
|
||||
]}
|
||||
name="chownGroup"
|
||||
helpText={translate('ChmodGroupHelpText')}
|
||||
helpTextWarning={translate('ChmodGroupHelpTextWarning')}
|
||||
values={fileDateOptions}
|
||||
onChange={onInputChange}
|
||||
{...settings.fileChmod}
|
||||
{...settings.chownGroup}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import _ from 'lodash';
|
||||
import { createAction } from 'redux-actions';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import requestAction from 'Utilities/requestAction';
|
||||
|
@ -10,11 +11,14 @@ import createHandleActions from './Creators/createHandleActions';
|
|||
|
||||
export const section = 'providerOptions';
|
||||
|
||||
const lastActions = {};
|
||||
let lastActionId = 0;
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
export const defaultState = {
|
||||
items: {},
|
||||
items: [],
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: false
|
||||
|
@ -23,8 +27,8 @@ export const defaultState = {
|
|||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_OPTIONS = 'devices/fetchOptions';
|
||||
export const CLEAR_OPTIONS = 'devices/clearOptions';
|
||||
export const FETCH_OPTIONS = 'providers/fetchOptions';
|
||||
export const CLEAR_OPTIONS = 'providers/clearOptions';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
@ -38,35 +42,55 @@ export const clearOptions = createAction(CLEAR_OPTIONS);
|
|||
export const actionHandlers = handleThunks({
|
||||
|
||||
[FETCH_OPTIONS]: function(getState, payload, dispatch) {
|
||||
const subsection = `${section}.${payload.section}`;
|
||||
|
||||
if (lastActions[payload.section] && _.isEqual(payload, lastActions[payload.section].payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionId = ++lastActionId;
|
||||
|
||||
lastActions[payload.section] = {
|
||||
actionId,
|
||||
payload
|
||||
};
|
||||
|
||||
dispatch(set({
|
||||
section,
|
||||
section: subsection,
|
||||
isFetching: true
|
||||
}));
|
||||
|
||||
const oldItems = getState().providerOptions.items;
|
||||
const itemSection = payload.itemSection;
|
||||
|
||||
const promise = requestAction(payload);
|
||||
|
||||
promise.done((data) => {
|
||||
oldItems[itemSection] = data.options || [];
|
||||
if (lastActions[payload.section]) {
|
||||
if (lastActions[payload.section].actionId === actionId) {
|
||||
lastActions[payload.section] = null;
|
||||
}
|
||||
|
||||
dispatch(set({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: true,
|
||||
error: null,
|
||||
items: oldItems
|
||||
}));
|
||||
dispatch(set({
|
||||
section: subsection,
|
||||
isFetching: false,
|
||||
isPopulated: true,
|
||||
error: null,
|
||||
items: data.options || []
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: xhr
|
||||
}));
|
||||
if (lastActions[payload.section]) {
|
||||
if (lastActions[payload.section].actionId === actionId) {
|
||||
lastActions[payload.section] = null;
|
||||
}
|
||||
|
||||
dispatch(set({
|
||||
section: subsection,
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: xhr
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -76,8 +100,12 @@ export const actionHandlers = handleThunks({
|
|||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[CLEAR_OPTIONS]: function(state) {
|
||||
return updateSectionState(state, section, defaultState);
|
||||
[CLEAR_OPTIONS]: function(state, { payload }) {
|
||||
const subsection = `${section}.${payload.section}`;
|
||||
|
||||
lastActions[payload.section] = null;
|
||||
|
||||
return updateSectionState(state, subsection, defaultState);
|
||||
}
|
||||
|
||||
}, defaultState, section);
|
||||
}, {}, section);
|
||||
|
|
|
@ -8,10 +8,10 @@ namespace NzbDrone.Api.Config
|
|||
{
|
||||
public class MediaManagementConfigModule : NzbDroneConfigModule<MediaManagementConfigResource>
|
||||
{
|
||||
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator)
|
||||
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator)
|
||||
: base(configService)
|
||||
{
|
||||
SharedValidator.RuleFor(c => c.FileChmod).SetValidator(fileChmodValidator).When(c => !string.IsNullOrEmpty(c.FileChmod) && (OsInfo.IsLinux || OsInfo.IsOsx));
|
||||
SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && PlatformInfo.IsMono);
|
||||
SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,8 @@ namespace NzbDrone.Api.Config
|
|||
public bool PathsDefaultStatic { get; set; }
|
||||
|
||||
public bool SetPermissionsLinux { get; set; }
|
||||
public string FileChmod { get; set; }
|
||||
public string ChmodFolder { get; set; }
|
||||
public string ChownGroup { get; set; }
|
||||
|
||||
public bool SkipFreeSpaceCheckWhenImporting { get; set; }
|
||||
public bool CopyUsingHardlinks { get; set; }
|
||||
|
@ -39,7 +40,8 @@ namespace NzbDrone.Api.Config
|
|||
AutoRenameFolders = model.AutoRenameFolders,
|
||||
|
||||
SetPermissionsLinux = model.SetPermissionsLinux,
|
||||
FileChmod = model.FileChmod,
|
||||
ChmodFolder = model.ChmodFolder,
|
||||
ChownGroup = model.ChownGroup,
|
||||
|
||||
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
|
||||
CopyUsingHardlinks = model.CopyUsingHardlinks,
|
||||
|
|
|
@ -30,7 +30,7 @@ namespace NzbDrone.Common.Disk
|
|||
public abstract long? GetAvailableSpace(string path);
|
||||
public abstract void InheritFolderPermissions(string filename);
|
||||
public abstract void SetEveryonePermissions(string filename);
|
||||
public abstract void SetPermissions(string path, string mask);
|
||||
public abstract void SetPermissions(string path, string mask, string group);
|
||||
public abstract void CopyPermissions(string sourcePath, string targetPath);
|
||||
public abstract long? GetTotalSize(string path);
|
||||
|
||||
|
@ -539,7 +539,7 @@ namespace NzbDrone.Common.Disk
|
|||
}
|
||||
}
|
||||
|
||||
public virtual bool IsValidFilePermissionMask(string mask)
|
||||
public virtual bool IsValidFolderPermissionMask(string mask)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ namespace NzbDrone.Common.Disk
|
|||
long? GetAvailableSpace(string path);
|
||||
void InheritFolderPermissions(string filename);
|
||||
void SetEveryonePermissions(string filename);
|
||||
void SetPermissions(string path, string mask);
|
||||
void SetPermissions(string path, string mask, string group);
|
||||
void CopyPermissions(string sourcePath, string targetPath);
|
||||
long? GetTotalSize(string path);
|
||||
DateTime FolderGetCreationTime(string path);
|
||||
|
@ -56,6 +56,6 @@ namespace NzbDrone.Common.Disk
|
|||
List<FileInfo> GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly);
|
||||
void RemoveEmptySubfolders(string path);
|
||||
void SaveStream(Stream stream, string path);
|
||||
bool IsValidFilePermissionMask(string mask);
|
||||
bool IsValidFolderPermissionMask(string mask);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,10 +19,10 @@ namespace NzbDrone.Core.Annotations
|
|||
public FieldType Type { get; set; }
|
||||
public bool Advanced { get; set; }
|
||||
public Type SelectOptions { get; set; }
|
||||
public string SelectOptionsProviderAction { get; set; }
|
||||
public string Section { get; set; }
|
||||
public HiddenType Hidden { get; set; }
|
||||
public PrivacyLevel Privacy { get; set; }
|
||||
public string RequestAction { get; set; }
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
|
||||
|
@ -39,6 +39,15 @@ namespace NzbDrone.Core.Annotations
|
|||
public string Hint { get; set; }
|
||||
}
|
||||
|
||||
public class FieldSelectOption
|
||||
{
|
||||
public int Value { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Order { get; set; }
|
||||
public string Hint { get; set; }
|
||||
public int? ParentValue { get; set; }
|
||||
}
|
||||
|
||||
public enum FieldType
|
||||
{
|
||||
Textbox,
|
||||
|
|
|
@ -317,11 +317,18 @@ namespace NzbDrone.Core.Configuration
|
|||
set { SetValue("SetPermissionsLinux", value); }
|
||||
}
|
||||
|
||||
public string FileChmod
|
||||
public string ChmodFolder
|
||||
{
|
||||
get { return GetValue("FileChmod", "0644"); }
|
||||
get { return GetValue("ChmodFolder", "755"); }
|
||||
|
||||
set { SetValue("FileChmod", value); }
|
||||
set { SetValue("ChmodFolder", value); }
|
||||
}
|
||||
|
||||
public string ChownGroup
|
||||
{
|
||||
get { return GetValue("ChownGroup", ""); }
|
||||
|
||||
set { SetValue("ChownGroup", value); }
|
||||
}
|
||||
|
||||
public int FirstDayOfWeek
|
||||
|
|
|
@ -45,7 +45,8 @@ namespace NzbDrone.Core.Configuration
|
|||
|
||||
//Permissions (Media Management)
|
||||
bool SetPermissionsLinux { get; set; }
|
||||
string FileChmod { get; set; }
|
||||
string ChmodFolder { get; set; }
|
||||
string ChownGroup { get; set; }
|
||||
|
||||
//Indexers
|
||||
int Retention { get; set; }
|
||||
|
|
|
@ -8,7 +8,7 @@ namespace NzbDrone.Core.Datastore.Migration
|
|||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Execute.Sql("DELETE FROM config WHERE Key IN ('folderchmod', 'chownuser', 'chowngroup', 'parsingleniency')");
|
||||
Execute.Sql("DELETE FROM config WHERE Key IN ('folderchmod', 'chownuser', 'parsingleniency')");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
using System;
|
||||
using System.Data;
|
||||
using FluentMigrator;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(187)]
|
||||
public class swap_filechmod_for_folderchmod : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
// Reverts part of migration 140, note that the v1 of migration140 also removed chowngroup
|
||||
Execute.WithConnection(ConvertFileChmodToFolderChmod);
|
||||
}
|
||||
|
||||
private void ConvertFileChmodToFolderChmod(IDbConnection conn, IDbTransaction tran)
|
||||
{
|
||||
using (IDbCommand getFileChmodCmd = conn.CreateCommand())
|
||||
{
|
||||
getFileChmodCmd.Transaction = tran;
|
||||
getFileChmodCmd.CommandText = @"SELECT Value FROM Config WHERE Key = 'filechmod'";
|
||||
|
||||
var fileChmod = getFileChmodCmd.ExecuteScalar() as string;
|
||||
if (fileChmod != null)
|
||||
{
|
||||
if (fileChmod.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
// Convert without using mono libraries. We take the 'r' bits and shifting them to the 'x' position, preserving everything else.
|
||||
var fileChmodNum = Convert.ToInt32(fileChmod, 8);
|
||||
var folderChmodNum = fileChmodNum | ((fileChmodNum & 0x124) >> 2);
|
||||
var folderChmod = Convert.ToString(folderChmodNum, 8).PadLeft(3, '0');
|
||||
|
||||
using (IDbCommand insertCmd = conn.CreateCommand())
|
||||
{
|
||||
insertCmd.Transaction = tran;
|
||||
insertCmd.CommandText = "INSERT INTO Config (Key, Value) VALUES ('chmodfolder', ?)";
|
||||
insertCmd.AddParameter(folderChmod);
|
||||
|
||||
insertCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
using (IDbCommand deleteCmd = conn.CreateCommand())
|
||||
{
|
||||
deleteCmd.Transaction = tran;
|
||||
deleteCmd.CommandText = "DELETE FROM Config WHERE Key = 'filechmod'";
|
||||
|
||||
deleteCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -91,8 +91,8 @@ namespace NzbDrone.Core.ImportLists.Radarr
|
|||
options = devices.OrderBy(d => d.Name, StringComparer.InvariantCultureIgnoreCase)
|
||||
.Select(d => new
|
||||
{
|
||||
id = d.Id,
|
||||
name = d.Name
|
||||
Value = d.Id,
|
||||
Name = d.Name
|
||||
})
|
||||
};
|
||||
}
|
||||
|
@ -106,8 +106,8 @@ namespace NzbDrone.Core.ImportLists.Radarr
|
|||
options = devices.OrderBy(d => d.Label, StringComparer.InvariantCultureIgnoreCase)
|
||||
.Select(d => new
|
||||
{
|
||||
id = d.Id,
|
||||
name = d.Label
|
||||
Value = d.Id,
|
||||
Name = d.Label
|
||||
})
|
||||
};
|
||||
}
|
||||
|
|
|
@ -34,10 +34,10 @@ namespace NzbDrone.Core.ImportLists.Radarr
|
|||
[FieldDefinition(1, Label = "API Key", Privacy = PrivacyLevel.ApiKey, HelpText = "Apikey of the Radarr V3 instance to import from")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(2, Type = FieldType.Device, RequestAction = "getProfiles", Label = "Profiles", HelpText = "Profiles from the source instance to import from")]
|
||||
[FieldDefinition(2, Type = FieldType.Select, SelectOptionsProviderAction = "getProfiles", Label = "Profiles", HelpText = "Profiles from the source instance to import from")]
|
||||
public IEnumerable<int> ProfileIds { get; set; }
|
||||
|
||||
[FieldDefinition(3, Type = FieldType.Device, RequestAction = "getTags", Label = "Tags", HelpText = "Tags from the source instance to import from")]
|
||||
[FieldDefinition(3, Type = FieldType.Select, SelectOptionsProviderAction = "getTags", Label = "Tags", HelpText = "Tags from the source instance to import from")]
|
||||
public IEnumerable<int> TagIds { get; set; }
|
||||
|
||||
public NzbDroneValidationResult Validate()
|
||||
|
|
|
@ -321,7 +321,14 @@ namespace NzbDrone.Core.Indexers
|
|||
_indexerStatusService.UpdateCookies(Definition.Id, cookies, expiration);
|
||||
};
|
||||
var generator = GetRequestGenerator();
|
||||
var releases = FetchPage(generator.GetRecentRequests().GetAllTiers().First().First(), parser);
|
||||
var firstRequest = generator.GetRecentRequests().GetAllTiers().FirstOrDefault()?.FirstOrDefault();
|
||||
|
||||
if (firstRequest == null)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, "No rss feed query available. This may be an issue with the indexer or your indexer category settings.");
|
||||
}
|
||||
|
||||
var releases = FetchPage(firstRequest, parser);
|
||||
|
||||
if (releases.Empty())
|
||||
{
|
||||
|
|
|
@ -159,5 +159,31 @@ namespace NzbDrone.Core.Indexers.Newznab
|
|||
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
|
||||
}
|
||||
}
|
||||
|
||||
public override object RequestAction(string action, IDictionary<string, string> query)
|
||||
{
|
||||
if (action == "newznabCategories")
|
||||
{
|
||||
List<NewznabCategory> categories = null;
|
||||
try
|
||||
{
|
||||
if (Settings.BaseUrl.IsNotNullOrWhiteSpace() && Settings.ApiPath.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
categories = _capabilitiesProvider.GetCapabilities(Settings).Categories;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Use default categories
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories)
|
||||
};
|
||||
}
|
||||
|
||||
return base.RequestAction(action, query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ namespace NzbDrone.Core.Indexers.Newznab
|
|||
}
|
||||
|
||||
var request = new HttpRequest(url, HttpAccept.Rss);
|
||||
request.AllowAutoRedirect = true;
|
||||
|
||||
HttpResponse response;
|
||||
|
||||
|
@ -76,6 +77,7 @@ namespace NzbDrone.Core.Indexers.Newznab
|
|||
{
|
||||
ex.WithData(response, 128 * 1024);
|
||||
_logger.Trace("Unexpected Response content ({0} bytes): {1}", response.ResponseData.Length, response.Content);
|
||||
_logger.Error(ex, "Failed to determine newznab api capabilities for {0}, using the defaults instead till Radarr restarts", indexerSettings.BaseUrl);
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Annotations;
|
||||
|
||||
namespace NzbDrone.Core.Indexers.Newznab
|
||||
{
|
||||
public static class NewznabCategoryFieldOptionsConverter
|
||||
{
|
||||
public static List<FieldSelectOption> GetFieldSelectOptions(List<NewznabCategory> categories)
|
||||
{
|
||||
// Categories not relevant for Radarr
|
||||
var ignoreCategories = new[] { 1000, 3000, 4000, 6000, 7000 };
|
||||
|
||||
// And maybe relevant for specific users
|
||||
var unimportantCategories = new[] { 0, 5000 };
|
||||
|
||||
var result = new List<FieldSelectOption>();
|
||||
|
||||
if (categories == null)
|
||||
{
|
||||
// Fetching categories failed, use default Newznab categories
|
||||
categories = new List<NewznabCategory>();
|
||||
categories.Add(new NewznabCategory
|
||||
{
|
||||
Id = 2000,
|
||||
Name = "Movies",
|
||||
Subcategories = new List<NewznabCategory>
|
||||
{
|
||||
new NewznabCategory { Id = 2010, Name = "Foreign" },
|
||||
new NewznabCategory { Id = 2020, Name = "Other" },
|
||||
new NewznabCategory { Id = 2030, Name = "SD" },
|
||||
new NewznabCategory { Id = 2040, Name = "HD" },
|
||||
new NewznabCategory { Id = 2050, Name = "BluRay" },
|
||||
new NewznabCategory { Id = 2060, Name = "3D" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var category in categories.Where(cat => !ignoreCategories.Contains(cat.Id)).OrderBy(cat => unimportantCategories.Contains(cat.Id)).ThenBy(cat => cat.Id))
|
||||
{
|
||||
result.Add(new FieldSelectOption
|
||||
{
|
||||
Value = category.Id,
|
||||
Name = category.Name,
|
||||
Hint = $"({category.Id})"
|
||||
});
|
||||
|
||||
if (category.Subcategories != null)
|
||||
{
|
||||
foreach (var subcat in category.Subcategories.OrderBy(cat => cat.Id))
|
||||
{
|
||||
result.Add(new FieldSelectOption
|
||||
{
|
||||
Value = subcat.Id,
|
||||
Name = subcat.Name,
|
||||
Hint = $"({subcat.Id})",
|
||||
ParentValue = category.Id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -75,7 +75,7 @@ namespace NzbDrone.Core.Indexers.Newznab
|
|||
[FieldDefinition(2, Label = "API Key", Privacy = PrivacyLevel.ApiKey)]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(3, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable all categories", Advanced = true)]
|
||||
[FieldDefinition(3, Label = "Categories", Type = FieldType.Select, SelectOptionsProviderAction = "newznabCategories", HelpText = "Comma Separated list, leave blank to disable all categories", Advanced = true)]
|
||||
public IEnumerable<int> Categories { get; set; }
|
||||
|
||||
[FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)]
|
||||
|
|
|
@ -148,5 +148,28 @@ namespace NzbDrone.Core.Indexers.Torznab
|
|||
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
|
||||
}
|
||||
}
|
||||
|
||||
public override object RequestAction(string action, IDictionary<string, string> query)
|
||||
{
|
||||
if (action == "newznabCategories")
|
||||
{
|
||||
List<NewznabCategory> categories = null;
|
||||
try
|
||||
{
|
||||
categories = _capabilitiesProvider.GetCapabilities(Settings).Categories;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Use default categories
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories)
|
||||
};
|
||||
}
|
||||
|
||||
return base.RequestAction(action, query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,6 +88,12 @@
|
|||
"ChangeHasNotBeenSavedYet": "Change has not been saved yet",
|
||||
"CheckDownloadClientForDetails": "check download client for more details",
|
||||
"CheckForFinishedDownloadsInterval": "Check For Finished Downloads Interval",
|
||||
"ChmodFolder": "chmod Folder",
|
||||
"ChmodFolderHelpText": "Octal, applied during import/rename to media folders and files (without execute bits)",
|
||||
"ChmodFolderHelpTextWarning": "This only works if the user running Radarr is the owner of the file. It's better to ensure the download client sets the permissions properly.",
|
||||
"ChmodGroup": "chmod Group",
|
||||
"ChmodGroupHelpText": "Group name or gid. Use gid for remote file systems.",
|
||||
"ChmodGroupHelpTextWarning": "This only works if the user running Radarr is the owner of the file. It's better to ensure the download client uses the same group as Radarr.",
|
||||
"ChooseAnotherFolder": "Choose another Folder",
|
||||
"CleanLibraryLevel": "Clean Library Level",
|
||||
"Clear": "Clear",
|
||||
|
@ -248,9 +254,6 @@
|
|||
"Failed": "Failed",
|
||||
"FailedDownloadHandling": "Failed Download Handling",
|
||||
"FailedLoadingSearchResults": "Failed to load search results, please try again.",
|
||||
"FileChmodHelpTexts1": "Octal, applied to media files when imported/renamed by Radarr",
|
||||
"FileChmodHelpTexts2": "The same mode is applied to movie/sub folders with the execute bit added, e.g., 0644 becomes 0755",
|
||||
"FileChmodMode": "File chmod mode",
|
||||
"FileDateHelpText": "Change file date on import/rescan",
|
||||
"FileManagement": "File Management",
|
||||
"Filename": "Filename",
|
||||
|
|
|
@ -205,8 +205,7 @@ namespace NzbDrone.Core.MediaFiles
|
|||
|
||||
try
|
||||
{
|
||||
var permissions = _configService.FileChmod;
|
||||
_diskProvider.SetPermissions(path, permissions);
|
||||
_diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
@ -54,7 +54,7 @@ namespace NzbDrone.Core.MediaFiles
|
|||
}
|
||||
else
|
||||
{
|
||||
SetMonoPermissions(path, _configService.FileChmod);
|
||||
SetMonoPermissions(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,7 +62,7 @@ namespace NzbDrone.Core.MediaFiles
|
|||
{
|
||||
if (OsInfo.IsNotWindows)
|
||||
{
|
||||
SetMonoPermissions(path, _configService.FileChmod);
|
||||
SetMonoPermissions(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,7 +75,7 @@ namespace NzbDrone.Core.MediaFiles
|
|||
}
|
||||
}
|
||||
|
||||
private void SetMonoPermissions(string path, string permissions)
|
||||
private void SetMonoPermissions(string path)
|
||||
{
|
||||
if (!_configService.SetPermissionsLinux)
|
||||
{
|
||||
|
@ -84,7 +84,7 @@ namespace NzbDrone.Core.MediaFiles
|
|||
|
||||
try
|
||||
{
|
||||
_diskProvider.SetPermissions(path, permissions);
|
||||
_diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
@ -28,7 +28,7 @@ namespace NzbDrone.Core.Notifications.PushBullet
|
|||
[FieldDefinition(0, Label = "Access Token", Privacy = PrivacyLevel.ApiKey, HelpLink = "https://www.pushbullet.com/#settings/account")]
|
||||
public string ApiKey { get; set; }
|
||||
|
||||
[FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device, RequestAction = "getDevices")]
|
||||
[FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device)]
|
||||
public IEnumerable<string> DeviceIds { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)]
|
||||
|
|
|
@ -149,7 +149,7 @@ namespace NzbDrone.Core.Update
|
|||
// Set executable flag on update app
|
||||
if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore))
|
||||
{
|
||||
_diskProvider.SetPermissions(_appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime), "0755");
|
||||
_diskProvider.SetPermissions(_appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime), "755", null);
|
||||
}
|
||||
|
||||
_logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath(updatePackage.Runtime));
|
||||
|
|
|
@ -3,11 +3,11 @@ using NzbDrone.Common.Disk;
|
|||
|
||||
namespace NzbDrone.Core.Validation
|
||||
{
|
||||
public class FileChmodValidator : PropertyValidator
|
||||
public class FolderChmodValidator : PropertyValidator
|
||||
{
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
|
||||
public FileChmodValidator(IDiskProvider diskProvider)
|
||||
public FolderChmodValidator(IDiskProvider diskProvider)
|
||||
: base("Must contain a valid Unix permissions octal")
|
||||
{
|
||||
_diskProvider = diskProvider;
|
||||
|
@ -20,7 +20,7 @@ namespace NzbDrone.Core.Validation
|
|||
return false;
|
||||
}
|
||||
|
||||
return _diskProvider.IsValidFilePermissionMask(context.PropertyValue.ToString());
|
||||
return _diskProvider.IsValidFolderPermissionMask(context.PropertyValue.ToString());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,10 @@
|
|||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
using Radarr.Api.V3.Indexers;
|
||||
using Radarr.Http.ClientSchema;
|
||||
|
||||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
|
@ -18,5 +21,184 @@ namespace NzbDrone.Integration.Test.ApiTests
|
|||
indexers.Should().NotContain(c => string.IsNullOrWhiteSpace(c.Name));
|
||||
indexers.Where(c => c.ConfigContract == typeof(NullConfig).Name).Should().OnlyContain(c => c.EnableRss);
|
||||
}
|
||||
|
||||
private IndexerResource GetNewznabSchemav2(string name = null)
|
||||
{
|
||||
var schema = Indexers.Schema().First(v => v.Implementation == "Newznab");
|
||||
|
||||
schema.Name = name;
|
||||
schema.EnableRss = false;
|
||||
schema.EnableAutomaticSearch = false;
|
||||
schema.EnableInteractiveSearch = false;
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
private IndexerResource GetNewznabSchemav3(string name = null)
|
||||
{
|
||||
var schema = Indexers.Schema().First(v => v.Implementation == "Newznab");
|
||||
|
||||
schema.Name = name;
|
||||
schema.EnableRss = false;
|
||||
schema.EnableAutomaticSearch = false;
|
||||
schema.EnableInteractiveSearch = false;
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
private Field GetCategoriesField(IndexerResource resource)
|
||||
{
|
||||
var field = resource.Fields.First(v => v.Name == "categories");
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v2_categories_should_be_array()
|
||||
{
|
||||
var schema = GetNewznabSchemav2();
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value.Should().BeOfType<JArray>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v3_categories_should_be_array()
|
||||
{
|
||||
var schema = GetNewznabSchemav3();
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value.Should().BeOfType<JArray>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v2_categories_should_accept_null()
|
||||
{
|
||||
var schema = GetNewznabSchemav2("Testv2null");
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value = null;
|
||||
|
||||
var result = Indexers.Post(schema);
|
||||
|
||||
var resultArray = GetCategoriesField(result).Value;
|
||||
resultArray.Should().BeOfType<JArray>();
|
||||
resultArray.As<JArray>().Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v2_categories_should_accept_emptystring()
|
||||
{
|
||||
var schema = GetNewznabSchemav2("Testv2emptystring");
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value = "";
|
||||
|
||||
var result = Indexers.Post(schema);
|
||||
|
||||
var resultArray = GetCategoriesField(result).Value;
|
||||
resultArray.Should().BeOfType<JArray>();
|
||||
resultArray.As<JArray>().Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v2_categories_should_accept_string()
|
||||
{
|
||||
var schema = GetNewznabSchemav2("Testv2string");
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value = "1000,1010";
|
||||
|
||||
var result = Indexers.Post(schema);
|
||||
|
||||
var resultArray = GetCategoriesField(result).Value;
|
||||
resultArray.Should().BeOfType<JArray>();
|
||||
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v2_categories_should_accept_array()
|
||||
{
|
||||
var schema = GetNewznabSchemav2("Testv2array");
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value = new object[] { 1000, 1010 };
|
||||
|
||||
var result = Indexers.Post(schema);
|
||||
|
||||
var resultArray = GetCategoriesField(result).Value;
|
||||
resultArray.Should().BeOfType<JArray>();
|
||||
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v3_categories_should_accept_null()
|
||||
{
|
||||
var schema = GetNewznabSchemav3("Testv3null");
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value = null;
|
||||
|
||||
var result = Indexers.Post(schema);
|
||||
|
||||
var resultArray = GetCategoriesField(result).Value;
|
||||
resultArray.Should().BeOfType<JArray>();
|
||||
resultArray.As<JArray>().Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v3_categories_should_accept_emptystring()
|
||||
{
|
||||
var schema = GetNewznabSchemav3("Testv3emptystring");
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value = "";
|
||||
|
||||
var result = Indexers.Post(schema);
|
||||
|
||||
var resultArray = GetCategoriesField(result).Value;
|
||||
resultArray.Should().BeOfType<JArray>();
|
||||
resultArray.As<JArray>().Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v3_categories_should_accept_string()
|
||||
{
|
||||
var schema = GetNewznabSchemav3("Testv3string");
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value = "1000,1010";
|
||||
|
||||
var result = Indexers.Post(schema);
|
||||
|
||||
var resultArray = GetCategoriesField(result).Value;
|
||||
resultArray.Should().BeOfType<JArray>();
|
||||
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void v3_categories_should_accept_array()
|
||||
{
|
||||
var schema = GetNewznabSchemav3("Testv3array");
|
||||
|
||||
var categoriesField = GetCategoriesField(schema);
|
||||
|
||||
categoriesField.Value = new object[] { 1000, 1010 };
|
||||
|
||||
var result = Indexers.Post(schema);
|
||||
|
||||
var resultArray = GetCategoriesField(result).Value;
|
||||
resultArray.Should().BeOfType<JArray>();
|
||||
resultArray.As<JArray>().ToObject<int[]>().Should().BeEquivalentTo(new[] { 1000, 1010 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
using Radarr.Api.V3.Indexers;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Radarr.Api.V3.Indexers;
|
||||
using RestSharp;
|
||||
|
||||
namespace NzbDrone.Integration.Test.Client
|
||||
|
@ -9,5 +11,11 @@ namespace NzbDrone.Integration.Test.Client
|
|||
: base(restClient, apiKey)
|
||||
{
|
||||
}
|
||||
|
||||
public List<IndexerResource> Schema()
|
||||
{
|
||||
var request = BuildRequest("/schema");
|
||||
return Get<List<IndexerResource>>(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,11 +17,32 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
|
|||
[Platform(Exclude = "Win")]
|
||||
public class DiskProviderFixture : DiskProviderFixtureBase<DiskProvider>
|
||||
{
|
||||
private string _tempPath;
|
||||
|
||||
public DiskProviderFixture()
|
||||
{
|
||||
PosixOnly();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void MonoDiskProviderFixtureTearDown()
|
||||
{
|
||||
// Give ourselves back write permissions so we can delete it
|
||||
if (_tempPath != null)
|
||||
{
|
||||
if (Directory.Exists(_tempPath))
|
||||
{
|
||||
Syscall.chmod(_tempPath, FilePermissions.S_IRWXU);
|
||||
}
|
||||
else if (File.Exists(_tempPath))
|
||||
{
|
||||
Syscall.chmod(_tempPath, FilePermissions.S_IRUSR | FilePermissions.S_IWUSR);
|
||||
}
|
||||
|
||||
_tempPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void SetWritePermissions(string path, bool writable)
|
||||
{
|
||||
if (Environment.UserName == "root")
|
||||
|
@ -29,16 +50,37 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
|
|||
Assert.Inconclusive("Need non-root user to test write permissions.");
|
||||
}
|
||||
|
||||
SetWritePermissionsInternal(path, writable, false);
|
||||
}
|
||||
|
||||
protected void SetWritePermissionsInternal(string path, bool writable, bool setgid)
|
||||
{
|
||||
// Remove Write permissions, we're still owner so we can clean it up, but we'll have to do that explicitly.
|
||||
var entry = UnixFileSystemInfo.GetFileSystemEntry(path);
|
||||
Stat stat;
|
||||
Syscall.stat(path, out stat);
|
||||
FilePermissions mode = stat.st_mode;
|
||||
|
||||
if (writable)
|
||||
{
|
||||
entry.FileAccessPermissions |= FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite;
|
||||
mode |= FilePermissions.S_IWUSR | FilePermissions.S_IWGRP | FilePermissions.S_IWOTH;
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.FileAccessPermissions &= ~(FileAccessPermissions.UserWrite | FileAccessPermissions.GroupWrite | FileAccessPermissions.OtherWrite);
|
||||
mode &= ~(FilePermissions.S_IWUSR | FilePermissions.S_IWGRP | FilePermissions.S_IWOTH);
|
||||
}
|
||||
|
||||
if (setgid)
|
||||
{
|
||||
mode |= FilePermissions.S_ISGID;
|
||||
}
|
||||
else
|
||||
{
|
||||
mode &= ~FilePermissions.S_ISGID;
|
||||
}
|
||||
|
||||
if (stat.st_mode != mode)
|
||||
{
|
||||
Syscall.chmod(path, mode);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -164,21 +206,22 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
|
|||
var tempFile = GetTempFilePath();
|
||||
|
||||
File.WriteAllText(tempFile, "File1");
|
||||
SetWritePermissions(tempFile, false);
|
||||
SetWritePermissionsInternal(tempFile, false, false);
|
||||
_tempPath = tempFile;
|
||||
|
||||
// Verify test setup
|
||||
Syscall.stat(tempFile, out var fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0444");
|
||||
|
||||
Subject.SetPermissions(tempFile, "644");
|
||||
Subject.SetPermissions(tempFile, "755", null);
|
||||
Syscall.stat(tempFile, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644");
|
||||
|
||||
Subject.SetPermissions(tempFile, "0644");
|
||||
Subject.SetPermissions(tempFile, "0755", null);
|
||||
Syscall.stat(tempFile, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0644");
|
||||
|
||||
Subject.SetPermissions(tempFile, "1664");
|
||||
Subject.SetPermissions(tempFile, "1775", null);
|
||||
Syscall.stat(tempFile, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1664");
|
||||
}
|
||||
|
@ -189,62 +232,118 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
|
|||
var tempPath = GetTempFilePath();
|
||||
|
||||
Directory.CreateDirectory(tempPath);
|
||||
SetWritePermissions(tempPath, false);
|
||||
SetWritePermissionsInternal(tempPath, false, false);
|
||||
_tempPath = tempPath;
|
||||
|
||||
// Verify test setup
|
||||
Syscall.stat(tempPath, out var fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0555");
|
||||
|
||||
Subject.SetPermissions(tempPath, "644");
|
||||
Subject.SetPermissions(tempPath, "755", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755");
|
||||
|
||||
Subject.SetPermissions(tempPath, "0644");
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755");
|
||||
|
||||
Subject.SetPermissions(tempPath, "1664");
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("1775");
|
||||
|
||||
Subject.SetPermissions(tempPath, "775");
|
||||
Subject.SetPermissions(tempPath, "775", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775");
|
||||
|
||||
Subject.SetPermissions(tempPath, "640");
|
||||
Subject.SetPermissions(tempPath, "750", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0750");
|
||||
|
||||
Subject.SetPermissions(tempPath, "0041");
|
||||
Subject.SetPermissions(tempPath, "051", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0051");
|
||||
|
||||
// reinstate sane permissions so fokder can be cleaned up
|
||||
Subject.SetPermissions(tempPath, "775");
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsValidFilePermissionMask_should_return_correct()
|
||||
public void should_preserve_setgid_on_set_folder_permissions()
|
||||
{
|
||||
// Files may not be executable
|
||||
Subject.IsValidFilePermissionMask("0777").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("0544").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("0454").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("0445").Should().BeFalse();
|
||||
var tempPath = GetTempFilePath();
|
||||
|
||||
Directory.CreateDirectory(tempPath);
|
||||
SetWritePermissionsInternal(tempPath, false, true);
|
||||
_tempPath = tempPath;
|
||||
|
||||
// Verify test setup
|
||||
Syscall.stat(tempPath, out var fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2555");
|
||||
|
||||
Subject.SetPermissions(tempPath, "755", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2755");
|
||||
|
||||
Subject.SetPermissions(tempPath, "775", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2775");
|
||||
|
||||
Subject.SetPermissions(tempPath, "750", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2750");
|
||||
|
||||
Subject.SetPermissions(tempPath, "051", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2051");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_clear_setgid_on_set_folder_permissions()
|
||||
{
|
||||
var tempPath = GetTempFilePath();
|
||||
|
||||
Directory.CreateDirectory(tempPath);
|
||||
SetWritePermissionsInternal(tempPath, false, true);
|
||||
_tempPath = tempPath;
|
||||
|
||||
// Verify test setup
|
||||
Syscall.stat(tempPath, out var fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("2555");
|
||||
|
||||
Subject.SetPermissions(tempPath, "0755", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0755");
|
||||
|
||||
Subject.SetPermissions(tempPath, "0775", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0775");
|
||||
|
||||
Subject.SetPermissions(tempPath, "0750", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0750");
|
||||
|
||||
Subject.SetPermissions(tempPath, "0051", null);
|
||||
Syscall.stat(tempPath, out fileStat);
|
||||
NativeConvert.ToOctalPermissionString(fileStat.st_mode).Should().Be("0051");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsValidFolderPermissionMask_should_return_correct()
|
||||
{
|
||||
// No special bits should be set
|
||||
Subject.IsValidFilePermissionMask("1644").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("2644").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("4644").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("7644").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("1755").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("2755").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("4755").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("7755").Should().BeFalse();
|
||||
|
||||
// Files should be readable and writeable by owner
|
||||
Subject.IsValidFilePermissionMask("0400").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("0000").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("0200").Should().BeFalse();
|
||||
Subject.IsValidFilePermissionMask("0600").Should().BeTrue();
|
||||
// Folder should be readable and writeable by owner
|
||||
Subject.IsValidFolderPermissionMask("000").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("100").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("200").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("300").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("400").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("500").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("600").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("700").Should().BeTrue();
|
||||
|
||||
// Folder should be readable and writeable by owner
|
||||
Subject.IsValidFolderPermissionMask("0000").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("0100").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("0200").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("0300").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("0400").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("0500").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("0600").Should().BeFalse();
|
||||
Subject.IsValidFolderPermissionMask("0700").Should().BeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,15 +61,29 @@ namespace NzbDrone.Mono.Disk
|
|||
{
|
||||
}
|
||||
|
||||
public override void SetPermissions(string path, string mask)
|
||||
public override void SetPermissions(string path, string mask, string group)
|
||||
{
|
||||
_logger.Debug("Setting permissions: {0} on {1}", mask, path);
|
||||
|
||||
var permissions = NativeConvert.FromOctalPermissionString(mask);
|
||||
|
||||
if (Directory.Exists(path))
|
||||
if (File.Exists(path))
|
||||
{
|
||||
permissions = GetFolderPermissions(permissions);
|
||||
permissions = GetFilePermissions(permissions);
|
||||
}
|
||||
|
||||
// Preserve non-access permissions
|
||||
if (Syscall.stat(path, out var curStat) < 0)
|
||||
{
|
||||
var error = Stdlib.GetLastError();
|
||||
|
||||
throw new LinuxPermissionsException("Error getting current permissions: " + error);
|
||||
}
|
||||
|
||||
// Preserve existing non-access permissions unless mask is 4 digits
|
||||
if (mask.Length < 4)
|
||||
{
|
||||
permissions |= curStat.st_mode & ~FilePermissions.ACCESSPERMS;
|
||||
}
|
||||
|
||||
if (Syscall.chmod(path, permissions) < 0)
|
||||
|
@ -78,33 +92,39 @@ namespace NzbDrone.Mono.Disk
|
|||
|
||||
throw new LinuxPermissionsException("Error setting permissions: " + error);
|
||||
}
|
||||
|
||||
var groupId = GetGroupId(group);
|
||||
|
||||
if (Syscall.chown(path, unchecked((uint)-1), groupId) < 0)
|
||||
{
|
||||
var error = Stdlib.GetLastError();
|
||||
|
||||
throw new LinuxPermissionsException("Error setting group: " + error);
|
||||
}
|
||||
}
|
||||
|
||||
private static FilePermissions GetFolderPermissions(FilePermissions permissions)
|
||||
private static FilePermissions GetFilePermissions(FilePermissions permissions)
|
||||
{
|
||||
permissions |= (FilePermissions)((int)(permissions & (FilePermissions.S_IRUSR | FilePermissions.S_IRGRP | FilePermissions.S_IROTH)) >> 2);
|
||||
permissions &= ~(FilePermissions.S_IXUSR | FilePermissions.S_IXGRP | FilePermissions.S_IXOTH);
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
public override bool IsValidFilePermissionMask(string mask)
|
||||
public override bool IsValidFolderPermissionMask(string mask)
|
||||
{
|
||||
try
|
||||
{
|
||||
var permissions = NativeConvert.FromOctalPermissionString(mask);
|
||||
|
||||
if ((permissions & (FilePermissions.S_ISUID | FilePermissions.S_ISGID | FilePermissions.S_ISVTX)) != 0)
|
||||
if ((permissions & ~FilePermissions.ACCESSPERMS) != 0)
|
||||
{
|
||||
// Only allow access permissions
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((permissions & (FilePermissions.S_IXUSR | FilePermissions.S_IXGRP | FilePermissions.S_IXOTH)) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((permissions & (FilePermissions.S_IRUSR | FilePermissions.S_IWUSR)) != (FilePermissions.S_IRUSR | FilePermissions.S_IWUSR))
|
||||
if ((permissions & FilePermissions.S_IRWXU) != FilePermissions.S_IRWXU)
|
||||
{
|
||||
// We expect at least full owner permissions (700)
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Runtime.ConstrainedExecution;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Permissions;
|
||||
|
@ -25,13 +25,13 @@ namespace NzbDrone.Mono.Interop
|
|||
|
||||
public override bool IsInvalid
|
||||
{
|
||||
get { return this.handle == new IntPtr(-1); }
|
||||
get { return handle == new IntPtr(-1); }
|
||||
}
|
||||
|
||||
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
return Syscall.close(this.handle.ToInt32()) != -1;
|
||||
return Syscall.close(handle.ToInt32()) != -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
|
@ -131,7 +131,7 @@ namespace NzbDrone.Update.UpdateEngine
|
|||
// Set executable flag on app
|
||||
if (OsInfo.IsOsx || (OsInfo.IsLinux && PlatformInfo.IsNetCore))
|
||||
{
|
||||
_diskProvider.SetPermissions(Path.Combine(installationFolder, "Radarr"), "0755");
|
||||
_diskProvider.SetPermissions(Path.Combine(installationFolder, "Radarr"), "755", null);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
|
@ -91,7 +91,7 @@ namespace NzbDrone.Windows.Disk
|
|||
}
|
||||
}
|
||||
|
||||
public override void SetPermissions(string path, string mask)
|
||||
public override void SetPermissions(string path, string mask, string group)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -8,11 +8,11 @@ namespace Radarr.Api.V3.Config
|
|||
{
|
||||
public class MediaManagementConfigModule : RadarrConfigModule<MediaManagementConfigResource>
|
||||
{
|
||||
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FileChmodValidator fileChmodValidator)
|
||||
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator, FolderChmodValidator folderChmodValidator)
|
||||
: base(configService)
|
||||
{
|
||||
SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0);
|
||||
SharedValidator.RuleFor(c => c.FileChmod).SetValidator(fileChmodValidator).When(c => !string.IsNullOrEmpty(c.FileChmod) && (OsInfo.IsLinux || OsInfo.IsOsx));
|
||||
SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx));
|
||||
SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
|
||||
SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100);
|
||||
}
|
||||
|
|
|
@ -19,7 +19,8 @@ namespace Radarr.Api.V3.Config
|
|||
public bool PathsDefaultStatic { get; set; }
|
||||
|
||||
public bool SetPermissionsLinux { get; set; }
|
||||
public string FileChmod { get; set; }
|
||||
public string ChmodFolder { get; set; }
|
||||
public string ChownGroup { get; set; }
|
||||
|
||||
public bool SkipFreeSpaceCheckWhenImporting { get; set; }
|
||||
public int MinimumFreeSpaceWhenImporting { get; set; }
|
||||
|
@ -46,7 +47,8 @@ namespace Radarr.Api.V3.Config
|
|||
AutoRenameFolders = model.AutoRenameFolders,
|
||||
|
||||
SetPermissionsLinux = model.SetPermissionsLinux,
|
||||
FileChmod = model.FileChmod,
|
||||
ChmodFolder = model.ChmodFolder,
|
||||
ChownGroup = model.ChownGroup,
|
||||
|
||||
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
|
||||
MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,
|
||||
|
|
|
@ -28,7 +28,7 @@ namespace Radarr.Api.V3
|
|||
Get("schema", x => GetTemplates());
|
||||
Post("test", x => Test(ReadResourceFromRequest(true)));
|
||||
Post("testall", x => TestAll());
|
||||
Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true)));
|
||||
Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true, true)));
|
||||
|
||||
GetResourceAll = GetAll;
|
||||
GetResourceById = GetProviderById;
|
||||
|
|
|
@ -15,9 +15,9 @@ namespace Radarr.Http.ClientSchema
|
|||
public string Type { get; set; }
|
||||
public bool Advanced { get; set; }
|
||||
public List<SelectOption> SelectOptions { get; set; }
|
||||
public string SelectOptionsProviderAction { get; set; }
|
||||
public string Section { get; set; }
|
||||
public string Hidden { get; set; }
|
||||
public string RequestAction { get; set; }
|
||||
|
||||
public Field Clone()
|
||||
{
|
||||
|
|
|
@ -100,13 +100,19 @@ namespace Radarr.Http.ClientSchema
|
|||
Order = fieldAttribute.Order,
|
||||
Advanced = fieldAttribute.Advanced,
|
||||
Type = fieldAttribute.Type.ToString().FirstCharToLower(),
|
||||
Section = fieldAttribute.Section,
|
||||
RequestAction = fieldAttribute.RequestAction
|
||||
Section = fieldAttribute.Section
|
||||
};
|
||||
|
||||
if (fieldAttribute.Type == FieldType.Select || fieldAttribute.Type == FieldType.TagSelect)
|
||||
{
|
||||
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions);
|
||||
if (fieldAttribute.SelectOptionsProviderAction.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
field.SelectOptionsProviderAction = fieldAttribute.SelectOptionsProviderAction;
|
||||
}
|
||||
else
|
||||
{
|
||||
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldAttribute.Hidden != HiddenType.Visible)
|
||||
|
@ -215,7 +221,11 @@ namespace Radarr.Http.ClientSchema
|
|||
{
|
||||
return fieldValue =>
|
||||
{
|
||||
if (fieldValue.GetType() == typeof(JArray))
|
||||
if (fieldValue == null)
|
||||
{
|
||||
return Enumerable.Empty<int>();
|
||||
}
|
||||
else if (fieldValue.GetType() == typeof(JArray))
|
||||
{
|
||||
return ((JArray)fieldValue).Select(s => s.Value<int>());
|
||||
}
|
||||
|
@ -229,7 +239,11 @@ namespace Radarr.Http.ClientSchema
|
|||
{
|
||||
return fieldValue =>
|
||||
{
|
||||
if (fieldValue.GetType() == typeof(JArray))
|
||||
if (fieldValue == null)
|
||||
{
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
else if (fieldValue.GetType() == typeof(JArray))
|
||||
{
|
||||
return ((JArray)fieldValue).Select(s => s.Value<string>());
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Nancy;
|
||||
using Nancy.Responses.Negotiation;
|
||||
using Newtonsoft.Json;
|
||||
|
@ -224,7 +225,7 @@ namespace Radarr.Http.REST
|
|||
return Negotiate.WithModel(model).WithStatusCode(statusCode);
|
||||
}
|
||||
|
||||
protected TResource ReadResourceFromRequest(bool skipValidate = false)
|
||||
protected TResource ReadResourceFromRequest(bool skipValidate = false, bool skipSharedValidate = false)
|
||||
{
|
||||
TResource resource;
|
||||
|
||||
|
@ -242,7 +243,12 @@ namespace Radarr.Http.REST
|
|||
throw new BadRequestException("Request body can't be empty");
|
||||
}
|
||||
|
||||
var errors = SharedValidator.Validate(resource).Errors.ToList();
|
||||
var errors = new List<ValidationFailure>();
|
||||
|
||||
if (!skipSharedValidate)
|
||||
{
|
||||
errors.AddRange(SharedValidator.Validate(resource).Errors);
|
||||
}
|
||||
|
||||
if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue