diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js
index 2053d3a30..4e3281169 100644
--- a/frontend/src/Components/Form/DeviceInputConnector.js
+++ b/frontend/src/Components/Form/DeviceInputConnector.js
@@ -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,
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css
index c3623c199..60cd28d69 100644
--- a/frontend/src/Components/Form/EnhancedSelectInput.css
+++ b/frontend/src/Components/Form/EnhancedSelectInput.css
@@ -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;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js
index 08690161d..197375bb6 100644
--- a/frontend/src/Components/Form/EnhancedSelectInput.js
+++ b/frontend/src/Components/Form/EnhancedSelectInput.js
@@ -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}
>
-
-
- {selectedOption ? selectedOption.value : null}
-
+ {
+ isEditable ?
+
+
+
+ {
+ isFetching &&
+
+ }
-
-
-
-
+ {
+ !isFetching &&
+
+ }
+
+
:
+
+
+ {selectedOption ? selectedOption.value : null}
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching &&
+
+ }
+
+
+ }
)}
@@ -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,
diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js
new file mode 100644
index 000000000..0311a920c
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputConnector.js
@@ -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 (
+
+ );
+ }
+}
+
+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);
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js
index f594a4335..fce64d2f7 100644
--- a/frontend/src/Components/Form/EnhancedSelectInputOption.js
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js
@@ -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 &&
+
+ }
+
{
isMultiSelect &&
{
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
index bfbe1e763..f5130d18b 100644
--- a/frontend/src/Components/Form/ProviderFieldFormGroup.js
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -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) {
{label}
@@ -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
};
diff --git a/frontend/src/Components/Form/UMaskInput.css b/frontend/src/Components/Form/UMaskInput.css
new file mode 100644
index 000000000..7b687caf9
--- /dev/null
+++ b/frontend/src/Components/Form/UMaskInput.css
@@ -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;
+}
diff --git a/frontend/src/Components/Form/UMaskInput.js b/frontend/src/Components/Form/UMaskInput.js
new file mode 100644
index 000000000..22f51c8fc
--- /dev/null
+++ b/frontend/src/Components/Form/UMaskInput.js
@@ -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: {v.hint} };
+ });
+
+ return (
+
+
+
+
+
+
+
{folder}
+
d{formatPermissions(folderNum)}
+
+
+
+
{file}
+
{formatPermissions(fileNum)}
+
+
+
+ );
+ }
+}
+
+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;
diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js
index 46f4c1b84..75812576f 100644
--- a/frontend/src/Helpers/Props/inputTypes.js
+++ b/frontend/src/Helpers/Props/inputTypes.js
@@ -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
];
diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js
index 4d6247b4b..00e1396a9 100644
--- a/frontend/src/Settings/MediaManagement/MediaManagement.js
+++ b/frontend/src/Settings/MediaManagement/MediaManagement.js
@@ -364,17 +364,32 @@ class MediaManagement extends Component {
advancedSettings={advancedSettings}
isAdvanced={true}
>
- {translate('FileChmodMode')}
+ {translate('ChmodFolder')}
+
+
+
+
+
+ {translate('ChmodGroup')}
diff --git a/frontend/src/Store/Actions/providerOptionActions.js b/frontend/src/Store/Actions/providerOptionActions.js
index 72a8b1745..4dc38a98f 100644
--- a/frontend/src/Store/Actions/providerOptionActions.js
+++ b/frontend/src/Store/Actions/providerOptionActions.js
@@ -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);
diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs b/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs
index ce316268d..43fb40963 100644
--- a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs
+++ b/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs
@@ -8,10 +8,10 @@ namespace NzbDrone.Api.Config
{
public class MediaManagementConfigModule : NzbDroneConfigModule
{
- 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));
}
diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs
index 512891fcb..27b04cd76 100644
--- a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs
+++ b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs
@@ -16,7 +16,8 @@ public class MediaManagementConfigResource : RestResource
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 @@ public static MediaManagementConfigResource ToResource(IConfigService model)
AutoRenameFolders = model.AutoRenameFolders,
SetPermissionsLinux = model.SetPermissionsLinux,
- FileChmod = model.FileChmod,
+ ChmodFolder = model.ChmodFolder,
+ ChownGroup = model.ChownGroup,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
CopyUsingHardlinks = model.CopyUsingHardlinks,
diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs
index 60cf6a623..440b7c57f 100644
--- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs
+++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs
@@ -30,7 +30,7 @@ public static StringComparison PathStringComparison
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 @@ public void SaveStream(Stream stream, string path)
}
}
- public virtual bool IsValidFilePermissionMask(string mask)
+ public virtual bool IsValidFolderPermissionMask(string mask)
{
throw new NotSupportedException();
}
diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs
index cb262504a..91524470f 100644
--- a/src/NzbDrone.Common/Disk/IDiskProvider.cs
+++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs
@@ -11,7 +11,7 @@ public interface IDiskProvider
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 @@ public interface IDiskProvider
List 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);
}
}
diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
index b887551b4..e68db5a2c 100644
--- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
+++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
@@ -19,10 +19,10 @@ public FieldDefinitionAttribute(int order)
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 @@ public FieldOptionAttribute(string label = null, [CallerLineNumber] int order =
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,
diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs
index 15066c55d..05ce821f7 100644
--- a/src/NzbDrone.Core/Configuration/ConfigService.cs
+++ b/src/NzbDrone.Core/Configuration/ConfigService.cs
@@ -317,11 +317,18 @@ public bool SetPermissionsLinux
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
diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs
index c4c24136c..cb42aa207 100644
--- a/src/NzbDrone.Core/Configuration/IConfigService.cs
+++ b/src/NzbDrone.Core/Configuration/IConfigService.cs
@@ -45,7 +45,8 @@ public interface IConfigService
//Permissions (Media Management)
bool SetPermissionsLinux { get; set; }
- string FileChmod { get; set; }
+ string ChmodFolder { get; set; }
+ string ChownGroup { get; set; }
//Indexers
int Retention { get; set; }
diff --git a/src/NzbDrone.Core/Datastore/Migration/175_remove_chown_and_folderchmod_config.cs b/src/NzbDrone.Core/Datastore/Migration/175_remove_chown_and_folderchmod_config.cs
index 6ea2e5d68..9e8f44aa3 100644
--- a/src/NzbDrone.Core/Datastore/Migration/175_remove_chown_and_folderchmod_config.cs
+++ b/src/NzbDrone.Core/Datastore/Migration/175_remove_chown_and_folderchmod_config.cs
@@ -8,7 +8,7 @@ public class remove_chown_and_folderchmod_config : NzbDroneMigrationBase
{
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')");
}
}
}
diff --git a/src/NzbDrone.Core/Datastore/Migration/187_swap_filechmod_for_folderchmod.cs b/src/NzbDrone.Core/Datastore/Migration/187_swap_filechmod_for_folderchmod.cs
new file mode 100644
index 000000000..a1580efa4
--- /dev/null
+++ b/src/NzbDrone.Core/Datastore/Migration/187_swap_filechmod_for_folderchmod.cs
@@ -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();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/ImportLists/Radarr/RadarrImport.cs b/src/NzbDrone.Core/ImportLists/Radarr/RadarrImport.cs
index 3b6e6eff6..92162c46b 100644
--- a/src/NzbDrone.Core/ImportLists/Radarr/RadarrImport.cs
+++ b/src/NzbDrone.Core/ImportLists/Radarr/RadarrImport.cs
@@ -91,8 +91,8 @@ public override object RequestAction(string action, IDictionary
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 @@ public override object RequestAction(string action, IDictionary
options = devices.OrderBy(d => d.Label, StringComparer.InvariantCultureIgnoreCase)
.Select(d => new
{
- id = d.Id,
- name = d.Label
+ Value = d.Id,
+ Name = d.Label
})
};
}
diff --git a/src/NzbDrone.Core/ImportLists/Radarr/RadarrSettings.cs b/src/NzbDrone.Core/ImportLists/Radarr/RadarrSettings.cs
index b9fc5659a..7d7052ab7 100644
--- a/src/NzbDrone.Core/ImportLists/Radarr/RadarrSettings.cs
+++ b/src/NzbDrone.Core/ImportLists/Radarr/RadarrSettings.cs
@@ -34,10 +34,10 @@ public RadarrSettings()
[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 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 TagIds { get; set; }
public NzbDroneValidationResult Validate()
diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs
index c0af6232f..260c92155 100644
--- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs
+++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs
@@ -321,7 +321,14 @@ protected virtual ValidationFailure TestConnection()
_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())
{
diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs
index 6486d0de0..3960579a2 100644
--- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs
+++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs
@@ -159,5 +159,31 @@ protected virtual ValidationFailure TestCapabilities()
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
}
}
+
+ public override object RequestAction(string action, IDictionary query)
+ {
+ if (action == "newznabCategories")
+ {
+ List 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);
+ }
}
}
diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs
index deae3ea5d..51074704a 100644
--- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs
+++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs
@@ -48,6 +48,7 @@ private NewznabCapabilities FetchCapabilities(NewznabSettings indexerSettings)
}
var request = new HttpRequest(url, HttpAccept.Rss);
+ request.AllowAutoRedirect = true;
HttpResponse response;
@@ -76,6 +77,7 @@ private NewznabCapabilities FetchCapabilities(NewznabSettings indexerSettings)
{
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;
diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs
new file mode 100644
index 000000000..d433ed244
--- /dev/null
+++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs
@@ -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 GetFieldSelectOptions(List 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();
+
+ if (categories == null)
+ {
+ // Fetching categories failed, use default Newznab categories
+ categories = new List();
+ categories.Add(new NewznabCategory
+ {
+ Id = 2000,
+ Name = "Movies",
+ Subcategories = new List
+ {
+ 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;
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs
index 4a3c77d94..ede334e3e 100644
--- a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs
+++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs
@@ -75,7 +75,7 @@ public NewznabSettings()
[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 Categories { get; set; }
[FieldDefinition(5, Label = "Additional Parameters", HelpText = "Additional Newznab parameters", Advanced = true)]
diff --git a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs
index 867209572..77041cb03 100644
--- a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs
+++ b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs
@@ -148,5 +148,28 @@ protected virtual ValidationFailure TestCapabilities()
return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details");
}
}
+
+ public override object RequestAction(string action, IDictionary query)
+ {
+ if (action == "newznabCategories")
+ {
+ List categories = null;
+ try
+ {
+ categories = _capabilitiesProvider.GetCapabilities(Settings).Categories;
+ }
+ catch
+ {
+ // Use default categories
+ }
+
+ return new
+ {
+ options = NewznabCategoryFieldOptionsConverter.GetFieldSelectOptions(categories)
+ };
+ }
+
+ return base.RequestAction(action, query);
+ }
}
}
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index f6a3e04f1..c5f85635f 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -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",
diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
index c08849eed..c11935005 100644
--- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
+++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
@@ -205,8 +205,7 @@ private void SetPermissions(string path)
try
{
- var permissions = _configService.FileChmod;
- _diskProvider.SetPermissions(path, permissions);
+ _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
}
catch (Exception ex)
{
diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs
index 3750af342..ae8d83667 100644
--- a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs
+++ b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs
@@ -54,7 +54,7 @@ public void SetFilePermissions(string path)
}
else
{
- SetMonoPermissions(path, _configService.FileChmod);
+ SetMonoPermissions(path);
}
}
@@ -62,7 +62,7 @@ public void SetFolderPermissions(string path)
{
if (OsInfo.IsNotWindows)
{
- SetMonoPermissions(path, _configService.FileChmod);
+ SetMonoPermissions(path);
}
}
@@ -75,7 +75,7 @@ public void SetFolderLastWriteTime(string path, DateTime time)
}
}
- private void SetMonoPermissions(string path, string permissions)
+ private void SetMonoPermissions(string path)
{
if (!_configService.SetPermissionsLinux)
{
@@ -84,7 +84,7 @@ private void SetMonoPermissions(string path, string permissions)
try
{
- _diskProvider.SetPermissions(path, permissions);
+ _diskProvider.SetPermissions(path, _configService.ChmodFolder, _configService.ChownGroup);
}
catch (Exception ex)
{
diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs
index 7d3167987..d4e9d0661 100644
--- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs
+++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs
@@ -28,7 +28,7 @@ public PushBulletSettings()
[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 DeviceIds { get; set; }
[FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)]
diff --git a/src/NzbDrone.Core/Update/InstallUpdateService.cs b/src/NzbDrone.Core/Update/InstallUpdateService.cs
index c13badb05..343af375d 100644
--- a/src/NzbDrone.Core/Update/InstallUpdateService.cs
+++ b/src/NzbDrone.Core/Update/InstallUpdateService.cs
@@ -149,7 +149,7 @@ private bool InstallUpdate(UpdatePackage updatePackage)
// 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));
diff --git a/src/NzbDrone.Core/Validation/FileChmodValidator.cs b/src/NzbDrone.Core/Validation/FolderChmodValidator.cs
similarity index 69%
rename from src/NzbDrone.Core/Validation/FileChmodValidator.cs
rename to src/NzbDrone.Core/Validation/FolderChmodValidator.cs
index c9f0881a7..3e90bf9fa 100644
--- a/src/NzbDrone.Core/Validation/FileChmodValidator.cs
+++ b/src/NzbDrone.Core/Validation/FolderChmodValidator.cs
@@ -3,11 +3,11 @@
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 @@ protected override bool IsValid(PropertyValidatorContext context)
return false;
}
- return _diskProvider.IsValidFilePermissionMask(context.PropertyValue.ToString());
+ return _diskProvider.IsValidFolderPermissionMask(context.PropertyValue.ToString());
}
}
}
diff --git a/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs
index 0124c83fd..fa53ce54c 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/IndexerFixture.cs
@@ -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 @@ public void should_have_built_in_indexer()
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();
+ }
+
+ [Test]
+ public void v3_categories_should_be_array()
+ {
+ var schema = GetNewznabSchemav3();
+
+ var categoriesField = GetCategoriesField(schema);
+
+ categoriesField.Value.Should().BeOfType();
+ }
+
+ [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();
+ resultArray.As().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();
+ resultArray.As().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();
+ resultArray.As().ToObject().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();
+ resultArray.As().ToObject().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();
+ resultArray.As().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();
+ resultArray.As().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();
+ resultArray.As().ToObject().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();
+ resultArray.As().ToObject().Should().BeEquivalentTo(new[] { 1000, 1010 });
+ }
}
}
diff --git a/src/NzbDrone.Integration.Test/Client/IndexerClient.cs b/src/NzbDrone.Integration.Test/Client/IndexerClient.cs
index 38c8eea52..190ae13bb 100644
--- a/src/NzbDrone.Integration.Test/Client/IndexerClient.cs
+++ b/src/NzbDrone.Integration.Test/Client/IndexerClient.cs
@@ -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 @@ public IndexerClient(IRestClient restClient, string apiKey)
: base(restClient, apiKey)
{
}
+
+ public List Schema()
+ {
+ var request = BuildRequest("/schema");
+ return Get>(request);
+ }
}
}
diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs
index 19f008757..0d076d1e1 100644
--- a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs
+++ b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs
@@ -17,11 +17,32 @@ namespace NzbDrone.Mono.Test.DiskProviderTests
[Platform(Exclude = "Win")]
public class DiskProviderFixture : DiskProviderFixtureBase
{
+ 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 @@ protected override void SetWritePermissions(string path, bool writable)
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 @@ public void should_set_file_permissions()
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 @@ public void should_set_folder_permissions()
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();
}
}
}
diff --git a/src/NzbDrone.Mono/Disk/DiskProvider.cs b/src/NzbDrone.Mono/Disk/DiskProvider.cs
index 6ea2549dd..86369b60a 100644
--- a/src/NzbDrone.Mono/Disk/DiskProvider.cs
+++ b/src/NzbDrone.Mono/Disk/DiskProvider.cs
@@ -61,15 +61,29 @@ public override void SetEveryonePermissions(string filename)
{
}
- 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 @@ public override void SetPermissions(string path, string mask)
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;
}
diff --git a/src/NzbDrone.Mono/Interop/SafeUnixHandle.cs b/src/NzbDrone.Mono/Interop/SafeUnixHandle.cs
index bad088ec5..22b8f167d 100644
--- a/src/NzbDrone.Mono/Interop/SafeUnixHandle.cs
+++ b/src/NzbDrone.Mono/Interop/SafeUnixHandle.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Runtime.ConstrainedExecution;
using System.Runtime.InteropServices;
using System.Security.Permissions;
@@ -25,13 +25,13 @@ public SafeUnixHandle(int fd)
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;
}
}
}
diff --git a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs
index 3ae5af9bf..ecf6c144a 100644
--- a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs
+++ b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
@@ -131,7 +131,7 @@ public void Start(string installationFolder, int processId)
// 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)
diff --git a/src/NzbDrone.Windows/Disk/DiskProvider.cs b/src/NzbDrone.Windows/Disk/DiskProvider.cs
index 943058a6e..3f648e66a 100644
--- a/src/NzbDrone.Windows/Disk/DiskProvider.cs
+++ b/src/NzbDrone.Windows/Disk/DiskProvider.cs
@@ -91,7 +91,7 @@ public override void SetEveryonePermissions(string filename)
}
}
- public override void SetPermissions(string path, string mask)
+ public override void SetPermissions(string path, string mask, string group)
{
}
diff --git a/src/Radarr.Api.V3/Config/MediaManagementConfigModule.cs b/src/Radarr.Api.V3/Config/MediaManagementConfigModule.cs
index 6a37a760e..fffedc515 100644
--- a/src/Radarr.Api.V3/Config/MediaManagementConfigModule.cs
+++ b/src/Radarr.Api.V3/Config/MediaManagementConfigModule.cs
@@ -8,11 +8,11 @@ namespace Radarr.Api.V3.Config
{
public class MediaManagementConfigModule : RadarrConfigModule
{
- 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);
}
diff --git a/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs b/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs
index 987eba574..2813530fa 100644
--- a/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs
+++ b/src/Radarr.Api.V3/Config/MediaManagementConfigResource.cs
@@ -19,7 +19,8 @@ public class MediaManagementConfigResource : RestResource
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 @@ public static MediaManagementConfigResource ToResource(IConfigService model)
AutoRenameFolders = model.AutoRenameFolders,
SetPermissionsLinux = model.SetPermissionsLinux,
- FileChmod = model.FileChmod,
+ ChmodFolder = model.ChmodFolder,
+ ChownGroup = model.ChownGroup,
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,
diff --git a/src/Radarr.Api.V3/ProviderModuleBase.cs b/src/Radarr.Api.V3/ProviderModuleBase.cs
index 7320181d4..281fc6a18 100644
--- a/src/Radarr.Api.V3/ProviderModuleBase.cs
+++ b/src/Radarr.Api.V3/ProviderModuleBase.cs
@@ -28,7 +28,7 @@ protected ProviderModuleBase(IProviderFactory pr
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;
diff --git a/src/Radarr.Http/ClientSchema/Field.cs b/src/Radarr.Http/ClientSchema/Field.cs
index eb8fa0edd..afa8d0300 100644
--- a/src/Radarr.Http/ClientSchema/Field.cs
+++ b/src/Radarr.Http/ClientSchema/Field.cs
@@ -15,9 +15,9 @@ public class Field
public string Type { get; set; }
public bool Advanced { get; set; }
public List 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()
{
diff --git a/src/Radarr.Http/ClientSchema/SchemaBuilder.cs b/src/Radarr.Http/ClientSchema/SchemaBuilder.cs
index 5710adc09..d05855d56 100644
--- a/src/Radarr.Http/ClientSchema/SchemaBuilder.cs
+++ b/src/Radarr.Http/ClientSchema/SchemaBuilder.cs
@@ -100,13 +100,19 @@ private static FieldMapping[] GetFieldMapping(Type type, string prefix, Func GetValueConverter(Type propertyType)
{
return fieldValue =>
{
- if (fieldValue.GetType() == typeof(JArray))
+ if (fieldValue == null)
+ {
+ return Enumerable.Empty();
+ }
+ else if (fieldValue.GetType() == typeof(JArray))
{
return ((JArray)fieldValue).Select(s => s.Value());
}
@@ -229,7 +239,11 @@ private static Func