1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2024-11-24 08:42:19 +02:00

New: Ability to change root folder when editing series

Closes #5544
This commit is contained in:
Mark McDowall 2024-11-23 20:21:24 -08:00
parent d14883a21c
commit cc80f24b46
14 changed files with 269 additions and 8 deletions

View File

@ -14,13 +14,14 @@ function FormInputButton({
className = styles.button, className = styles.button,
canSpin = false, canSpin = false,
isLastButton = true, isLastButton = true,
kind = kinds.PRIMARY,
...otherProps ...otherProps
}: FormInputButtonProps) { }: FormInputButtonProps) {
if (canSpin) { if (canSpin) {
return ( return (
<SpinnerButton <SpinnerButton
className={classNames(className, !isLastButton && styles.middleButton)} className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY} kind={kind}
{...otherProps} {...otherProps}
/> />
); );
@ -29,7 +30,7 @@ function FormInputButton({
return ( return (
<Button <Button
className={classNames(className, !isLastButton && styles.middleButton)} className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY} kind={kind}
{...otherProps} {...otherProps}
/> />
); );

View File

@ -145,6 +145,7 @@ interface FormInputGroupProps<T> {
autoFocus?: boolean; autoFocus?: boolean;
includeNoChange?: boolean; includeNoChange?: boolean;
includeNoChangeDisabled?: boolean; includeNoChangeDisabled?: boolean;
valueOptions?: object;
selectedValueOptions?: object; selectedValueOptions?: object;
indexerFlags?: number; indexerFlags?: number;
pending?: boolean; pending?: boolean;

View File

@ -16,3 +16,7 @@
height: 35px; height: 35px;
} }
.fileBrowserMiddleButton {
composes: middleButton from '~./FormInputButton.css';
}

View File

@ -2,6 +2,7 @@
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'fileBrowserButton': string; 'fileBrowserButton': string;
'fileBrowserMiddleButton': string;
'hasFileBrowser': string; 'hasFileBrowser': string;
'inputWrapper': string; 'inputWrapper': string;
'pathMatch': string; 'pathMatch': string;

View File

@ -1,3 +1,4 @@
import classNames from 'classnames';
import React, { import React, {
KeyboardEvent, KeyboardEvent,
SyntheticEvent, SyntheticEvent,
@ -29,6 +30,7 @@ interface PathInputProps {
value?: string; value?: string;
placeholder?: string; placeholder?: string;
includeFiles: boolean; includeFiles: boolean;
hasButton?: boolean;
hasFileBrowser?: boolean; hasFileBrowser?: boolean;
onChange: (change: InputChanged<string>) => void; onChange: (change: InputChanged<string>) => void;
} }
@ -96,6 +98,7 @@ export function PathInputInternal(props: PathInputInternalProps) {
value: inputValue = '', value: inputValue = '',
paths, paths,
includeFiles, includeFiles,
hasButton,
hasFileBrowser = true, hasFileBrowser = true,
onChange, onChange,
onFetchPaths, onFetchPaths,
@ -229,9 +232,12 @@ export function PathInputInternal(props: PathInputInternalProps) {
/> />
{hasFileBrowser ? ( {hasFileBrowser ? (
<div> <>
<FormInputButton <FormInputButton
className={styles.fileBrowserButton} className={classNames(
styles.fileBrowserButton,
hasButton && styles.fileBrowserMiddleButton
)}
onPress={handleFileBrowserOpenPress} onPress={handleFileBrowserOpenPress}
> >
<Icon name={icons.FOLDER_OPEN} /> <Icon name={icons.FOLDER_OPEN} />
@ -245,7 +251,7 @@ export function PathInputInternal(props: PathInputInternalProps) {
onChange={onChange} onChange={onChange}
onModalClose={handleFileBrowserModalClose} onModalClose={handleFileBrowserModalClose}
/> />
</div> </>
) : null} ) : null}
</div> </div>
); );

View File

@ -3,7 +3,10 @@ import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import usePrevious from 'Helpers/Hooks/usePrevious'; import usePrevious from 'Helpers/Hooks/usePrevious';
import { addRootFolder } from 'Store/Actions/rootFolderActions'; import {
addRootFolder,
fetchRootFolders,
} from 'Store/Actions/rootFolderActions';
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector'; import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs'; import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@ -189,6 +192,10 @@ function RootFolderSelectInput({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
dispatch(fetchRootFolders());
}, [dispatch]);
return ( return (
<> <>
<EnhancedSelectInput <EnhancedSelectInput

View File

@ -65,6 +65,7 @@ import {
faFilter as fasFilter, faFilter as fasFilter,
faFlag as fasFlag, faFlag as fasFlag,
faFolderOpen as fasFolderOpen, faFolderOpen as fasFolderOpen,
faFolderTree as farFolderTree,
faForward as fasForward, faForward as fasForward,
faHeart as fasHeart, faHeart as fasHeart,
faHistory as fasHistory, faHistory as fasHistory,
@ -201,6 +202,7 @@ export const REMOVE = fasTimes;
export const RESTART = fasRedoAlt; export const RESTART = fasRedoAlt;
export const RESTORE = fasHistory; export const RESTORE = fasHistory;
export const REORDER = fasBars; export const REORDER = fasBars;
export const ROOT_FOLDER = farFolderTree;
export const RSS = fasRss; export const RSS = fasRss;
export const SAVE = fasSave; export const SAVE = fasSave;
export const SCENE_MAPPING = fasSitemap; export const SCENE_MAPPING = fasSitemap;

View File

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

View File

@ -4,6 +4,7 @@ import SeriesMonitorNewItemsOptionsPopoverContent from 'AddSeries/SeriesMonitorN
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import Form from 'Components/Form/Form'; import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@ -21,6 +22,8 @@ import { saveSeries, setSeriesValue } from 'Store/Actions/seriesActions';
import selectSettings from 'Store/Selectors/selectSettings'; import selectSettings from 'Store/Selectors/selectSettings';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import RootFolderModal from './RootFolder/RootFolderModal';
import { RootFolderUpdated } from './RootFolder/RootFolderModalContent';
import styles from './EditSeriesModalContent.css'; import styles from './EditSeriesModalContent.css';
export interface EditSeriesModalContentProps { export interface EditSeriesModalContentProps {
@ -43,11 +46,17 @@ function EditSeriesModalContent({
seriesType, seriesType,
path, path,
tags, tags,
rootFolderPath: initialRootFolderPath,
} = useSeries(seriesId)!; } = useSeries(seriesId)!;
const { isSaving, saveError, pendingChanges } = useSelector( const { isSaving, saveError, pendingChanges } = useSelector(
(state: AppState) => state.series (state: AppState) => state.series
); );
const [isRootFolderModalOpen, setIsRootFolderModalOpen] = useState(false);
const [rootFolderPath, setRootFolderPath] = useState(initialRootFolderPath);
const isPathChanging = pendingChanges.path && path !== pendingChanges.path; const isPathChanging = pendingChanges.path && path !== pendingChanges.path;
const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false); const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false);
@ -86,6 +95,26 @@ function EditSeriesModalContent({
[dispatch] [dispatch]
); );
const handleRootFolderPress = useCallback(() => {
setIsRootFolderModalOpen(true);
}, []);
const handleRootFolderModalClose = useCallback(() => {
setIsRootFolderModalOpen(false);
}, []);
const handleRootFolderChange = useCallback(
({
path: newPath,
rootFolderPath: newRootFolderPath,
}: RootFolderUpdated) => {
setIsRootFolderModalOpen(false);
setRootFolderPath(newRootFolderPath);
handleInputChange({ name: 'path', value: newPath });
},
[handleInputChange]
);
const handleCancelPress = useCallback(() => { const handleCancelPress = useCallback(() => {
setIsConfirmMoveModalOpen(false); setIsConfirmMoveModalOpen(false);
}, []); }, []);
@ -196,6 +225,16 @@ function EditSeriesModalContent({
type={inputTypes.PATH} type={inputTypes.PATH}
name="path" name="path"
{...settings.path} {...settings.path}
buttons={[
<FormInputButton
key="fileBrowser"
kind={kinds.DEFAULT}
title="Root Folder"
onPress={handleRootFolderPress}
>
<Icon name={icons.ROOT_FOLDER} />
</FormInputButton>,
]}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</FormGroup> </FormGroup>
@ -233,6 +272,14 @@ function EditSeriesModalContent({
</SpinnerErrorButton> </SpinnerErrorButton>
</ModalFooter> </ModalFooter>
<RootFolderModal
isOpen={isRootFolderModalOpen}
seriesId={seriesId}
rootFolderPath={rootFolderPath}
onSavePress={handleRootFolderChange}
onModalClose={handleRootFolderModalClose}
/>
<MoveSeriesModal <MoveSeriesModal
originalPath={path} originalPath={path}
destinationPath={pendingChanges.path} destinationPath={pendingChanges.path}

View File

@ -0,0 +1,26 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import RootFolderModalContent, {
RootFolderModalContentProps,
} from './RootFolderModalContent';
interface RootFolderModalProps extends RootFolderModalContentProps {
isOpen: boolean;
}
function RootFolderModal(props: RootFolderModalProps) {
const { isOpen, rootFolderPath, seriesId, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<RootFolderModalContent
seriesId={seriesId}
rootFolderPath={rootFolderPath}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default RootFolderModal;

View File

@ -0,0 +1,93 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import { InputChanged } from 'typings/inputs';
import useApiQuery from 'Utilities/Hooks/useApiQuery';
import translate from 'Utilities/String/translate';
export interface RootFolderUpdated {
path: string;
rootFolderPath: string;
}
export interface RootFolderModalContentProps {
seriesId: number;
rootFolderPath: string;
onSavePress(change: RootFolderUpdated): void;
onModalClose(): void;
}
interface SeriesFolder {
folder: string;
}
function RootFolderModalContent(props: RootFolderModalContentProps) {
const { seriesId, onSavePress, onModalClose } = props;
const { isWindows } = useSelector(createSystemStatusSelector());
const [rootFolderPath, setRootFolderPath] = useState(props.rootFolderPath);
const { isLoading, data } = useApiQuery<SeriesFolder>({
url: `/series/${seriesId}/folder`,
});
const onInputChange = useCallback(({ value }: InputChanged<string>) => {
setRootFolderPath(value);
}, []);
const handleSavePress = useCallback(() => {
const separator = isWindows ? '\\' : '/';
onSavePress({
path: `${rootFolderPath}${separator}${data?.folder}`,
rootFolderPath,
});
}, [rootFolderPath, isWindows, data, onSavePress]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('UpdateSeriesPath')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
value={rootFolderPath}
valueOptions={{
seriesFolder: data?.folder,
isWindows,
}}
selectedValueOptions={{
seriesFolder: data?.folder,
isWindows,
}}
helpText={translate('SeriesEditRootFolderHelpText')}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button disabled={isLoading || !data?.folder} onPress={handleSavePress}>
{translate('UpdatePath')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default RootFolderModalContent;

View File

@ -2085,6 +2085,8 @@
"UpdateStartupTranslocationHealthCheckMessage": "Cannot install update because startup folder '{startupFolder}' is in an App Translocation folder.", "UpdateStartupTranslocationHealthCheckMessage": "Cannot install update because startup folder '{startupFolder}' is in an App Translocation folder.",
"UpdateUiNotWritableHealthCheckMessage": "Cannot install update because UI folder '{uiFolder}' is not writable by the user '{userName}'.", "UpdateUiNotWritableHealthCheckMessage": "Cannot install update because UI folder '{uiFolder}' is not writable by the user '{userName}'.",
"UpdaterLogFiles": "Updater Log Files", "UpdaterLogFiles": "Updater Log Files",
"UpdatePath": "Update Path",
"UpdateSeriesPath": "Update Series Path",
"Updates": "Updates", "Updates": "Updates",
"UpgradeUntil": "Upgrade Until", "UpgradeUntil": "Upgrade Until",
"UpgradeUntilCustomFormatScore": "Upgrade Until Custom Format Score", "UpgradeUntilCustomFormatScore": "Upgrade Until Custom Format Score",

View File

@ -219,10 +219,10 @@ private string GetBestRootFolderPathInternal(string path)
{ {
var osPath = new OsPath(path); var osPath = new OsPath(path);
return osPath.Directory.ToString().TrimEnd(osPath.IsUnixPath ? '/' : '\\'); return osPath.Directory.ToString().GetCleanPath();
} }
return possibleRootFolder.Path; return possibleRootFolder.Path.GetCleanPath();
} }
} }
} }

View File

@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Organizer;
using NzbDrone.Core.Tv;
using Sonarr.Http;
namespace Sonarr.Api.V3.Series;
[V3ApiController("series")]
public class SeriesFolderController : Controller
{
private readonly ISeriesService _seriesService;
private readonly IBuildFileNames _fileNameBuilder;
public SeriesFolderController(ISeriesService seriesService, IBuildFileNames fileNameBuilder)
{
_seriesService = seriesService;
_fileNameBuilder = fileNameBuilder;
}
[HttpGet("{id}/folder")]
public object GetFolder([FromRoute] int id)
{
var series = _seriesService.GetSeries(id);
var folder = _fileNameBuilder.GetSeriesFolder(series);
return new
{
folder
};
}
}