diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index 853bd4590..058330897 100644 --- a/frontend/src/App/AppRoutes.tsx +++ b/frontend/src/App/AppRoutes.tsx @@ -15,7 +15,7 @@ import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadCl import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; -import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector'; +import MediaManagement from 'Settings/MediaManagement/MediaManagement'; import MetadataSettings from 'Settings/Metadata/MetadataSettings'; import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings'; import NotificationSettings from 'Settings/Notifications/NotificationSettings'; @@ -98,10 +98,7 @@ function AppRoutes() { - + diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index ab046fba9..7016182f2 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -21,6 +21,7 @@ import Notification from 'typings/Notification'; import QualityDefinition from 'typings/QualityDefinition'; import QualityProfile from 'typings/QualityProfile'; import General from 'typings/Settings/General'; +import MediaManagement from 'typings/Settings/MediaManagement'; import NamingConfig from 'typings/Settings/NamingConfig'; import NamingExample from 'typings/Settings/NamingExample'; import ReleaseProfile from 'typings/Settings/ReleaseProfile'; @@ -54,6 +55,10 @@ export interface GeneralAppState extends AppSectionItemState, AppSectionSaveState {} +export interface MediaManagementAppState + extends AppSectionItemState, + AppSectionSaveState {} + export interface NamingAppState extends AppSectionItemState, AppSectionSaveState {} @@ -131,6 +136,7 @@ interface SettingsAppState { indexerFlags: IndexerFlagSettingsAppState; indexers: IndexerAppState; languages: LanguageSettingsAppState; + mediaManagement: MediaManagementAppState; metadata: MetadataAppState; naming: NamingAppState; namingExamples: NamingExamplesAppState; diff --git a/frontend/src/Helpers/Hooks/useIsWindows.ts b/frontend/src/Helpers/Hooks/useIsWindows.ts new file mode 100644 index 000000000..c4729c4c0 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useIsWindows.ts @@ -0,0 +1,8 @@ +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; + +function useIsWindows() { + return useSelector((state: AppState) => state.system.status.item.isWindows); +} + +export default useIsWindows; diff --git a/frontend/src/Helpers/elementChildren.js b/frontend/src/Helpers/elementChildren.js deleted file mode 100644 index 1c10b2f0e..000000000 --- a/frontend/src/Helpers/elementChildren.js +++ /dev/null @@ -1,149 +0,0 @@ -// https://github.com/react-bootstrap/react-element-children - -import React from 'react'; - -/** - * Iterates through children that are typically specified as `props.children`, - * but only maps over children that are "valid components". - * - * The mapFunction provided index will be normalised to the components mapped, - * so an invalid component would not increase the index. - * - * @param {?*} children Children tree container. - * @param {function(*, int)} func. - * @param {*} context Context for func. - * @return {object} Object containing the ordered map of results. - */ -export function map(children, func, context) { - let index = 0; - - return React.Children.map(children, (child) => { - if (!React.isValidElement(child)) { - return child; - } - - return func.call(context, child, index++); - }); -} - -/** - * Iterates through children that are "valid components". - * - * The provided forEachFunc(child, index) will be called for each - * leaf child with the index reflecting the position relative to "valid components". - * - * @param {?*} children Children tree container. - * @param {function(*, int)} func. - * @param {*} context Context for context. - */ -export function forEach(children, func, context) { - let index = 0; - - React.Children.forEach(children, (child) => { - if (!React.isValidElement(child)) { - return; - } - - func.call(context, child, index++); - }); -} - -/** - * Count the number of "valid components" in the Children container. - * - * @param {?*} children Children tree container. - * @returns {number} - */ -export function count(children) { - let result = 0; - - React.Children.forEach(children, (child) => { - if (!React.isValidElement(child)) { - return; - } - - ++result; - }); - - return result; -} - -/** - * Finds children that are typically specified as `props.children`, - * but only iterates over children that are "valid components". - * - * The provided forEachFunc(child, index) will be called for each - * leaf child with the index reflecting the position relative to "valid components". - * - * @param {?*} children Children tree container. - * @param {function(*, int)} func. - * @param {*} context Context for func. - * @returns {array} of children that meet the func return statement - */ -export function filter(children, func, context) { - const result = []; - - forEach(children, (child, index) => { - if (func.call(context, child, index)) { - result.push(child); - } - }); - - return result; -} - -export function find(children, func, context) { - let result = null; - - forEach(children, (child, index) => { - if (result) { - return; - } - if (func.call(context, child, index)) { - result = child; - } - }); - - return result; -} - -export function every(children, func, context) { - let result = true; - - forEach(children, (child, index) => { - if (!result) { - return; - } - if (!func.call(context, child, index)) { - result = false; - } - }); - - return result; -} - -export function some(children, func, context) { - let result = false; - - forEach(children, (child, index) => { - if (result) { - return; - } - - if (func.call(context, child, index)) { - result = true; - } - }); - - return result; -} - -export function toArray(children) { - const result = []; - - forEach(children, (child) => { - result.push(child); - }); - - return result; -} diff --git a/frontend/src/Helpers/getDisplayName.js b/frontend/src/Helpers/getDisplayName.js deleted file mode 100644 index 512702c87..000000000 --- a/frontend/src/Helpers/getDisplayName.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function getDisplayName(Component) { - return Component.displayName || Component.name || 'Component'; -} diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js deleted file mode 100644 index d63a8a7d2..000000000 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ /dev/null @@ -1,536 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import FieldSet from 'Components/FieldSet'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import RootFolders from 'RootFolder/RootFolders'; -import SettingsToolbar from 'Settings/SettingsToolbar'; -import translate from 'Utilities/String/translate'; -import Naming from './Naming/Naming'; -import AddRootFolder from './RootFolder/AddRootFolder'; - -const episodeTitleRequiredOptions = [ - { - key: 'always', - get value() { - return translate('Always'); - } - }, - { - key: 'bulkSeasonReleases', - get value() { - return translate('OnlyForBulkSeasonReleases'); - } - }, - { - key: 'never', - get value() { - return translate('Never'); - } - } -]; - -const rescanAfterRefreshOptions = [ - { - key: 'always', - get value() { - return translate('Always'); - } - }, - { - key: 'afterManual', - get value() { - return translate('AfterManualRefresh'); - } - }, - { - key: 'never', - get value() { - return translate('Never'); - } - } -]; - -const downloadPropersAndRepacksOptions = [ - { - key: 'preferAndUpgrade', - get value() { - return translate('PreferAndUpgrade'); - } - }, - { - key: 'doNotUpgrade', - get value() { - return translate('DoNotUpgradeAutomatically'); - } - }, - { - key: 'doNotPrefer', - get value() { - return translate('DoNotPrefer'); - } - } -]; - -const fileDateOptions = [ - { - key: 'none', - get value() { - return translate('None'); - } - }, - { - key: 'localAirDate', - get value() { - return translate('LocalAirDate'); - } - }, - { - key: 'utcAirDate', - get value() { - return translate('UtcAirDate'); - } - } -]; - -class MediaManagement extends Component { - - // - // Render - - render() { - const { - advancedSettings, - isFetching, - error, - settings, - hasSettings, - isWindows, - onInputChange, - onSavePress, - ...otherProps - } = this.props; - - return ( - - - - - - - { - isFetching ? -
- -
: null - } - - { - !isFetching && error ? -
- - {translate('MediaManagementSettingsLoadError')} - -
: null - } - - { - hasSettings && !isFetching && !error ? -
- { - advancedSettings ? -
- - {translate('CreateEmptySeriesFolders')} - - - - - - {translate('DeleteEmptyFolders')} - - - -
: null - } - - { - advancedSettings ? -
- - {translate('EpisodeTitleRequired')} - - - - - - {translate('SkipFreeSpaceCheck')} - - - - - - {translate('MinimumFreeSpace')} - - - - - - {translate('UseHardlinksInsteadOfCopy')} - - - - - - {translate('ImportUsingScript')} - - - - - { - settings.useScriptImport.value ? - - {translate('ImportScriptPath')} - - - : null - } - - - {translate('ImportExtraFiles')} - - - - - { - settings.importExtraFiles.value ? - - {translate('ImportExtraFiles')} - - - : null - } -
: null - } - -
- - {translate('UnmonitorDeletedEpisodes')} - - - - - - {translate('DownloadPropersAndRepacks')} - - - - - - {translate('AnalyseVideoFiles')} - - - - - - {translate('RescanSeriesFolderAfterRefresh')} - - - - - - {translate('ChangeFileDate')} - - - - - - {translate('RecyclingBin')} - - - - - - {translate('RecyclingBinCleanup')} - - - -
- - { - advancedSettings && !isWindows ? -
- - {translate('SetPermissions')} - - - - - - {translate('ChmodFolder')} - - - - - - {translate('ChownGroup')} - - - -
: null - } -
: null - } - -
- - -
-
-
- ); - } - -} - -MediaManagement.propTypes = { - advancedSettings: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - error: PropTypes.object, - settings: PropTypes.object.isRequired, - hasSettings: PropTypes.bool.isRequired, - isWindows: PropTypes.bool.isRequired, - onSavePress: PropTypes.func.isRequired, - onInputChange: PropTypes.func.isRequired -}; - -export default MediaManagement; diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.tsx b/frontend/src/Settings/MediaManagement/MediaManagement.tsx new file mode 100644 index 000000000..0fce5a2f0 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagement.tsx @@ -0,0 +1,559 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Alert from 'Components/Alert'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import useShowAdvancedSettings from 'Helpers/Hooks/useShowAdvancedSettings'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import RootFolders from 'RootFolder/RootFolders'; +import SettingsToolbar from 'Settings/SettingsToolbar'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { + fetchMediaManagementSettings, + saveMediaManagementSettings, + saveNamingSettings, + setMediaManagementSettingsValue, +} from 'Store/Actions/settingsActions'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import useIsWindows from 'System/useIsWindows'; +import { InputChanged } from 'typings/inputs'; +import isEmpty from 'Utilities/Object/isEmpty'; +import translate from 'Utilities/String/translate'; +import Naming from './Naming/Naming'; +import AddRootFolder from './RootFolder/AddRootFolder'; + +const SECTION = 'mediaManagement'; + +const episodeTitleRequiredOptions = [ + { + key: 'always', + get value() { + return translate('Always'); + }, + }, + { + key: 'bulkSeasonReleases', + get value() { + return translate('OnlyForBulkSeasonReleases'); + }, + }, + { + key: 'never', + get value() { + return translate('Never'); + }, + }, +]; + +const rescanAfterRefreshOptions = [ + { + key: 'always', + get value() { + return translate('Always'); + }, + }, + { + key: 'afterManual', + get value() { + return translate('AfterManualRefresh'); + }, + }, + { + key: 'never', + get value() { + return translate('Never'); + }, + }, +]; + +const downloadPropersAndRepacksOptions = [ + { + key: 'preferAndUpgrade', + get value() { + return translate('PreferAndUpgrade'); + }, + }, + { + key: 'doNotUpgrade', + get value() { + return translate('DoNotUpgradeAutomatically'); + }, + }, + { + key: 'doNotPrefer', + get value() { + return translate('DoNotPrefer'); + }, + }, +]; + +const fileDateOptions = [ + { + key: 'none', + get value() { + return translate('None'); + }, + }, + { + key: 'localAirDate', + get value() { + return translate('LocalAirDate'); + }, + }, + { + key: 'utcAirDate', + get value() { + return translate('UtcAirDate'); + }, + }, +]; + +function MediaManagement() { + const dispatch = useDispatch(); + const showAdvancedSettings = useShowAdvancedSettings(); + const hasNamingPendingChanges = !isEmpty( + useSelector((state: AppState) => state.settings.naming.pendingChanges) + ); + const isWindows = useIsWindows(); + const { + isFetching, + isPopulated, + isSaving, + error, + settings, + hasSettings, + hasPendingChanges, + validationErrors, + validationWarnings, + } = useSelector(createSettingsSectionSelector(SECTION)); + + const handleSavePress = useCallback(() => { + dispatch(saveMediaManagementSettings()); + dispatch(saveNamingSettings()); + }, [dispatch]); + + const handleInputChange = useCallback( + (change: InputChanged) => { + // @ts-expect-error - actions are not typed + dispatch(setMediaManagementSettingsValue(change)); + }, + [dispatch] + ); + + useEffect(() => { + dispatch(fetchMediaManagementSettings()); + + return () => { + dispatch(clearPendingChanges({ section: `settings.${SECTION}` })); + }; + }, [dispatch]); + + return ( + + + + + + + {isFetching ? ( +
+ +
+ ) : null} + + {!isFetching && error ? ( +
+ + {translate('MediaManagementSettingsLoadError')} + +
+ ) : null} + + {hasSettings && isPopulated && !error ? ( +
+ {showAdvancedSettings ? ( +
+ + {translate('CreateEmptySeriesFolders')} + + + + + + {translate('DeleteEmptyFolders')} + + + +
+ ) : null} + + {showAdvancedSettings ? ( +
+ + {translate('EpisodeTitleRequired')} + + + + + + {translate('SkipFreeSpaceCheck')} + + + + + + {translate('MinimumFreeSpace')} + + + + + + + {translate('UseHardlinksInsteadOfCopy')} + + + + + + + {translate('ImportUsingScript')} + + + + + {settings.useScriptImport.value ? ( + + {translate('ImportScriptPath')} + + + + ) : null} + + + {translate('ImportExtraFiles')} + + + + + {settings.importExtraFiles.value ? ( + + {translate('ImportExtraFiles')} + + + + ) : null} +
+ ) : null} + +
+ + {translate('UnmonitorDeletedEpisodes')} + + + + + + {translate('DownloadPropersAndRepacks')} + + + + + + {translate('AnalyseVideoFiles')} + + + + + + + {translate('RescanSeriesFolderAfterRefresh')} + + + + + + + {translate('ChangeFileDate')} + + + + + + {translate('RecyclingBin')} + + + + + + {translate('RecyclingBinCleanup')} + + + +
+ + {showAdvancedSettings && !isWindows ? ( +
+ + {translate('SetPermissions')} + + + + + + {translate('ChmodFolder')} + + + + + + {translate('ChownGroup')} + + + +
+ ) : null} +
+ ) : null} + +
+ + +
+
+
+ ); +} + +export default MediaManagement; diff --git a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js deleted file mode 100644 index 9d6f959b8..000000000 --- a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js +++ /dev/null @@ -1,86 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; -import { fetchMediaManagementSettings, saveMediaManagementSettings, saveNamingSettings, setMediaManagementSettingsValue } from 'Store/Actions/settingsActions'; -import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import MediaManagement from './MediaManagement'; - -const SECTION = 'mediaManagement'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.advancedSettings, - (state) => state.settings.naming, - createSettingsSectionSelector(SECTION), - createSystemStatusSelector(), - (advancedSettings, namingSettings, sectionSettings, systemStatus) => { - return { - advancedSettings, - ...sectionSettings, - hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges, - isWindows: systemStatus.isWindows - }; - } - ); -} - -const mapDispatchToProps = { - fetchMediaManagementSettings, - setMediaManagementSettingsValue, - saveMediaManagementSettings, - saveNamingSettings, - clearPendingChanges -}; - -class MediaManagementConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchMediaManagementSettings(); - } - - componentWillUnmount() { - this.props.clearPendingChanges({ section: `settings.${SECTION}` }); - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.setMediaManagementSettingsValue({ name, value }); - }; - - onSavePress = () => { - this.props.saveMediaManagementSettings(); - this.props.saveNamingSettings(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -MediaManagementConnector.propTypes = { - fetchMediaManagementSettings: PropTypes.func.isRequired, - setMediaManagementSettingsValue: PropTypes.func.isRequired, - saveMediaManagementSettings: PropTypes.func.isRequired, - saveNamingSettings: PropTypes.func.isRequired, - clearPendingChanges: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(MediaManagementConnector); diff --git a/frontend/src/System/useIsWindows.ts b/frontend/src/System/useIsWindows.ts new file mode 100644 index 000000000..c4729c4c0 --- /dev/null +++ b/frontend/src/System/useIsWindows.ts @@ -0,0 +1,8 @@ +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; + +function useIsWindows() { + return useSelector((state: AppState) => state.system.status.item.isWindows); +} + +export default useIsWindows; diff --git a/frontend/src/typings/Settings/MediaManagement.ts b/frontend/src/typings/Settings/MediaManagement.ts new file mode 100644 index 000000000..ac589d3c9 --- /dev/null +++ b/frontend/src/typings/Settings/MediaManagement.ts @@ -0,0 +1,22 @@ +export default interface MediaManagement { + autoUnmonitorPreviouslyDownloadedEpisodes: boolean; + recycleBin: string; + recycleBinCleanupDays: number; + downloadPropersAndRepacks: string; + createEmptySeriesFolders: boolean; + deleteEmptyFolders: boolean; + fileDate: string; + rescanAfterRefresh: string; + setPermissionsLinux: boolean; + chmodFolder: string; + chownGroup: string; + episodeTitleRequired: string; + skipFreeSpaceCheckWhenImporting: boolean; + minimumFreeSpaceWhenImporting: number; + copyUsingHardlinks: boolean; + useScriptImport: boolean; + scriptImportPath: string; + importExtraFiles: boolean; + extraFileExtensions: string; + enableMediaInfo: boolean; +}