mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-23 22:25:56 -04:00
New: Manage episodes through Manual Import modal
This commit is contained in:
@@ -26,6 +26,12 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
composes: button from '~Components/Link/Button.css';
|
||||
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.importMode,
|
||||
.bulkSelect {
|
||||
composes: select from '~Components/Form/SelectInput.css';
|
||||
|
||||
@@ -112,13 +112,21 @@ class InteractiveImportModalContent extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const instanceColumns = _.cloneDeep(columns);
|
||||
|
||||
if (!props.showSeries) {
|
||||
instanceColumns.find((c) => c.name === 'series').isVisible = false;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
allSelected: false,
|
||||
allUnselected: false,
|
||||
lastToggled: null,
|
||||
selectedState: {},
|
||||
invalidRowsSelected: [],
|
||||
selectModalOpen: null
|
||||
withoutEpisodeFileIdRowsSelected: [],
|
||||
selectModalOpen: null,
|
||||
columns: instanceColumns
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,9 +144,14 @@ class InteractiveImportModalContent extends Component {
|
||||
this.setState(selectAll(this.state.selectedState, value));
|
||||
}
|
||||
|
||||
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||
onSelectedChange = ({ id, value, hasEpisodeFileId, shiftKey = false }) => {
|
||||
this.setState((state) => {
|
||||
return toggleSelected(state, this.props.items, id, value, shiftKey);
|
||||
return {
|
||||
...toggleSelected(state, this.props.items, id, value, shiftKey),
|
||||
withoutEpisodeFileIdRowsSelected: hasEpisodeFileId || !value ?
|
||||
_.without(state.withoutEpisodeFileIdRowsSelected, id) :
|
||||
[...state.withoutEpisodeFileIdRowsSelected, id]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +169,16 @@ class InteractiveImportModalContent extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteSelectedPress = () => {
|
||||
const {
|
||||
onDeleteSelectedPress
|
||||
} = this.props;
|
||||
|
||||
const selected = this.getSelectedIds();
|
||||
|
||||
onDeleteSelectedPress(selected);
|
||||
}
|
||||
|
||||
onImportSelectedPress = () => {
|
||||
const {
|
||||
downloadId,
|
||||
@@ -193,7 +216,9 @@ class InteractiveImportModalContent extends Component {
|
||||
const {
|
||||
downloadId,
|
||||
allowSeriesChange,
|
||||
autoSelectRow,
|
||||
showFilterExistingFiles,
|
||||
showDelete,
|
||||
showImportMode,
|
||||
filterExistingFiles,
|
||||
title,
|
||||
@@ -215,6 +240,7 @@ class InteractiveImportModalContent extends Component {
|
||||
allUnselected,
|
||||
selectedState,
|
||||
invalidRowsSelected,
|
||||
withoutEpisodeFileIdRowsSelected,
|
||||
selectModalOpen
|
||||
} = this.state;
|
||||
|
||||
@@ -308,7 +334,7 @@ class InteractiveImportModalContent extends Component {
|
||||
{
|
||||
isPopulated && !!items.length && !isFetching && !isFetching &&
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={this.state.columns}
|
||||
horizontalScroll={true}
|
||||
selectAll={true}
|
||||
allSelected={allSelected}
|
||||
@@ -327,6 +353,8 @@ class InteractiveImportModalContent extends Component {
|
||||
isSelected={selectedState[item.id]}
|
||||
{...item}
|
||||
allowSeriesChange={allowSeriesChange}
|
||||
autoSelectRow={autoSelectRow}
|
||||
columns={this.state.columns}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
onValidRowChange={this.onValidRowChange}
|
||||
/>
|
||||
@@ -345,6 +373,19 @@ class InteractiveImportModalContent extends Component {
|
||||
|
||||
<ModalFooter className={styles.footer}>
|
||||
<div className={styles.leftButtons}>
|
||||
{
|
||||
showDelete ?
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
isDisabled={!selectedIds.length || !!withoutEpisodeFileIdRowsSelected.length}
|
||||
onPress={onModalClose}
|
||||
>
|
||||
Delete
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!downloadId && showImportMode ?
|
||||
<SelectInput
|
||||
@@ -437,7 +478,10 @@ class InteractiveImportModalContent extends Component {
|
||||
|
||||
InteractiveImportModalContent.propTypes = {
|
||||
downloadId: PropTypes.string,
|
||||
showSeries: PropTypes.bool.isRequired,
|
||||
allowSeriesChange: PropTypes.bool.isRequired,
|
||||
autoSelectRow: PropTypes.bool.isRequired,
|
||||
showDelete: PropTypes.bool.isRequired,
|
||||
showImportMode: PropTypes.bool.isRequired,
|
||||
showFilterExistingFiles: PropTypes.bool.isRequired,
|
||||
filterExistingFiles: PropTypes.bool.isRequired,
|
||||
@@ -454,13 +498,17 @@ InteractiveImportModalContent.propTypes = {
|
||||
onSortPress: PropTypes.func.isRequired,
|
||||
onFilterExistingFilesChange: PropTypes.func.isRequired,
|
||||
onImportModeChange: PropTypes.func.isRequired,
|
||||
onDeleteSelectedPress: PropTypes.func.isRequired,
|
||||
onImportSelectedPress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
InteractiveImportModalContent.defaultProps = {
|
||||
showSeries: true,
|
||||
allowSeriesChange: true,
|
||||
autoSelectRow: true,
|
||||
showFilterExistingFiles: false,
|
||||
showDelete: false,
|
||||
showImportMode: true,
|
||||
importMode: 'move'
|
||||
};
|
||||
|
||||
+116
-19
@@ -1,14 +1,42 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { sortDirections } from 'Helpers/Props';
|
||||
import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode } from 'Store/Actions/interactiveImportActions';
|
||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { updateEpisodeFiles, deleteEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import InteractiveImportModalContent from './InteractiveImportModalContent';
|
||||
|
||||
function isSameEpisodeFile(file, originalFile) {
|
||||
const {
|
||||
series,
|
||||
seasonNumber,
|
||||
episodes
|
||||
} = file;
|
||||
|
||||
if (!originalFile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!originalFile.series || series.id !== originalFile.series.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (seasonNumber !== originalFile.seasonNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const episodeIds = episodes.map((e) => e.id);
|
||||
const originalEpisodeIds = originalFile.episodes ? originalFile.episodes.map((e) => e.id) : [];
|
||||
|
||||
return episodeIds.every((episodeId) => {
|
||||
return originalEpisodeIds.indexOf(episodeId) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createClientSideCollectionSelector('interactiveImport'),
|
||||
@@ -23,6 +51,8 @@ const mapDispatchToProps = {
|
||||
dispatchSetInteractiveImportSort: setInteractiveImportSort,
|
||||
dispatchSetInteractiveImportMode: setInteractiveImportMode,
|
||||
dispatchClearInteractiveImport: clearInteractiveImport,
|
||||
dispatchUpdateEpisodeFiles: updateEpisodeFiles,
|
||||
dispatchDeleteEpisodeFiles: deleteEpisodeFiles,
|
||||
dispatchExecuteCommand: executeCommand
|
||||
};
|
||||
|
||||
@@ -44,16 +74,34 @@ class InteractiveImportModalContentConnector extends Component {
|
||||
const {
|
||||
downloadId,
|
||||
seriesId,
|
||||
folder
|
||||
seasonNumber,
|
||||
folder,
|
||||
initialSortKey,
|
||||
initialSortDirection,
|
||||
dispatchSetInteractiveImportSort,
|
||||
dispatchFetchInteractiveImportItems
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
filterExistingFiles
|
||||
} = this.state;
|
||||
|
||||
this.props.dispatchFetchInteractiveImportItems({
|
||||
if (initialSortKey) {
|
||||
const sortProps = {
|
||||
sortKey: initialSortKey
|
||||
};
|
||||
|
||||
if (initialSortDirection) {
|
||||
sortProps.sortDirection = initialSortDirection;
|
||||
}
|
||||
|
||||
dispatchSetInteractiveImportSort(sortProps);
|
||||
}
|
||||
|
||||
dispatchFetchInteractiveImportItems({
|
||||
downloadId,
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
folder,
|
||||
filterExistingFiles
|
||||
});
|
||||
@@ -99,10 +147,23 @@ class InteractiveImportModalContentConnector extends Component {
|
||||
this.props.dispatchSetInteractiveImportMode({ importMode });
|
||||
}
|
||||
|
||||
onDeleteSelectedPress = (selected) => {
|
||||
// TODO: Delete selected (if they have episode IDs)
|
||||
}
|
||||
|
||||
onImportSelectedPress = (selected, importMode) => {
|
||||
const {
|
||||
items,
|
||||
originalItems,
|
||||
dispatchUpdateEpisodeFiles,
|
||||
dispatchExecuteCommand,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const existingFiles = [];
|
||||
const files = [];
|
||||
|
||||
_.forEach(this.props.items, (item) => {
|
||||
items.forEach((item) => {
|
||||
const isSelected = selected.indexOf(item.id) > -1;
|
||||
|
||||
if (isSelected) {
|
||||
@@ -112,32 +173,48 @@ class InteractiveImportModalContentConnector extends Component {
|
||||
episodes,
|
||||
releaseGroup,
|
||||
quality,
|
||||
language
|
||||
language,
|
||||
episodeFileId
|
||||
} = item;
|
||||
|
||||
if (!series) {
|
||||
this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' });
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(seasonNumber)) {
|
||||
this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' });
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!episodes || !episodes.length) {
|
||||
this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' });
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!quality) {
|
||||
this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' });
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!language) {
|
||||
this.setState({ interactiveImportErrorMessage: 'Language must be chosen for each selected file' });
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (episodeFileId) {
|
||||
const originalItem = originalItems.find((i) => i.id === item.id);
|
||||
|
||||
if (isSameEpisodeFile(item, originalItem)) {
|
||||
existingFiles.push({
|
||||
id: episodeFileId,
|
||||
releaseGroup,
|
||||
quality,
|
||||
language
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
files.push({
|
||||
@@ -148,22 +225,35 @@ class InteractiveImportModalContentConnector extends Component {
|
||||
releaseGroup,
|
||||
quality,
|
||||
language,
|
||||
downloadId: this.props.downloadId
|
||||
downloadId: this.props.downloadId,
|
||||
episodeFileId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!files.length) {
|
||||
return;
|
||||
let shouldClose = false;
|
||||
|
||||
if (existingFiles.length) {
|
||||
dispatchUpdateEpisodeFiles({
|
||||
files: existingFiles
|
||||
});
|
||||
|
||||
shouldClose = true;
|
||||
}
|
||||
|
||||
this.props.dispatchExecuteCommand({
|
||||
name: commandNames.INTERACTIVE_IMPORT,
|
||||
files,
|
||||
importMode
|
||||
});
|
||||
if (files.length) {
|
||||
dispatchExecuteCommand({
|
||||
name: commandNames.INTERACTIVE_IMPORT,
|
||||
files,
|
||||
importMode
|
||||
});
|
||||
|
||||
this.props.onModalClose();
|
||||
shouldClose = true;
|
||||
}
|
||||
|
||||
if (shouldClose) {
|
||||
onModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
@@ -183,6 +273,7 @@ class InteractiveImportModalContentConnector extends Component {
|
||||
onSortPress={this.onSortPress}
|
||||
onFilterExistingFilesChange={this.onFilterExistingFilesChange}
|
||||
onImportModeChange={this.onImportModeChange}
|
||||
onDeleteSelectedPress={this.onDeleteSelectedPress}
|
||||
onImportSelectedPress={this.onImportSelectedPress}
|
||||
/>
|
||||
);
|
||||
@@ -192,13 +283,19 @@ class InteractiveImportModalContentConnector extends Component {
|
||||
InteractiveImportModalContentConnector.propTypes = {
|
||||
downloadId: PropTypes.string,
|
||||
seriesId: PropTypes.number,
|
||||
seasonNumber: PropTypes.number,
|
||||
folder: PropTypes.string,
|
||||
filterExistingFiles: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
initialSortKey: PropTypes.string,
|
||||
initialSortDirection: PropTypes.oneOf(sortDirections.all),
|
||||
originalItems: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
dispatchFetchInteractiveImportItems: PropTypes.func.isRequired,
|
||||
dispatchSetInteractiveImportSort: PropTypes.func.isRequired,
|
||||
dispatchSetInteractiveImportMode: PropTypes.func.isRequired,
|
||||
dispatchClearInteractiveImport: PropTypes.func.isRequired,
|
||||
dispatchUpdateEpisodeFiles: PropTypes.func.isRequired,
|
||||
dispatchDeleteEpisodeFiles: PropTypes.func.isRequired,
|
||||
dispatchExecuteCommand: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
@@ -41,23 +41,35 @@ class InteractiveImportRow extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
allowSeriesChange,
|
||||
id,
|
||||
series,
|
||||
seasonNumber,
|
||||
episodes,
|
||||
quality,
|
||||
language
|
||||
language,
|
||||
episodeFileId,
|
||||
columns
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
allowSeriesChange &&
|
||||
series &&
|
||||
seasonNumber != null &&
|
||||
episodes.length &&
|
||||
quality &&
|
||||
language
|
||||
) {
|
||||
this.props.onSelectedChange({ id, value: true });
|
||||
this.props.onSelectedChange({
|
||||
id,
|
||||
hasEpisodeFileId: !!episodeFileId,
|
||||
value: true
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isSeriesColumnVisible: columns.find((c) => c.name === 'series').isVisible
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -104,17 +116,34 @@ class InteractiveImportRow extends Component {
|
||||
selectRowAfterChange = (value) => {
|
||||
const {
|
||||
id,
|
||||
episodeFileId,
|
||||
isSelected
|
||||
} = this.props;
|
||||
|
||||
if (!isSelected && value === true) {
|
||||
this.props.onSelectedChange({ id, value });
|
||||
this.props.onSelectedChange({
|
||||
id,
|
||||
hasEpisodeFileId: !!episodeFileId,
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSelectedChange = (result) => {
|
||||
const {
|
||||
episodeFileId,
|
||||
onSelectedChange
|
||||
} = this.props;
|
||||
|
||||
onSelectedChange({
|
||||
...result,
|
||||
hasEpisodeFileId: !!episodeFileId
|
||||
});
|
||||
}
|
||||
|
||||
onSelectSeriesPress = () => {
|
||||
this.setState({ isSelectSeriesModalOpen: true });
|
||||
}
|
||||
@@ -186,8 +215,7 @@ class InteractiveImportRow extends Component {
|
||||
size,
|
||||
rejections,
|
||||
isReprocessing,
|
||||
isSelected,
|
||||
onSelectedChange
|
||||
isSelected
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
@@ -224,7 +252,7 @@ class InteractiveImportRow extends Component {
|
||||
<TableSelectCell
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
onSelectedChange={onSelectedChange}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
/>
|
||||
|
||||
<TableRowCell
|
||||
@@ -234,15 +262,19 @@ class InteractiveImportRow extends Component {
|
||||
{relativePath}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCellButton
|
||||
isDisabled={!allowSeriesChange}
|
||||
title={allowSeriesChange ? 'Click to change series' : undefined}
|
||||
onPress={this.onSelectSeriesPress}
|
||||
>
|
||||
{
|
||||
showSeriesPlaceholder ? <InteractiveImportRowCellPlaceholder /> : seriesTitle
|
||||
}
|
||||
</TableRowCellButton>
|
||||
{
|
||||
this.state.isSeriesColumnVisible ?
|
||||
<TableRowCellButton
|
||||
isDisabled={!allowSeriesChange}
|
||||
title={allowSeriesChange ? 'Click to change series' : undefined}
|
||||
onPress={this.onSelectSeriesPress}
|
||||
>
|
||||
{
|
||||
showSeriesPlaceholder ? <InteractiveImportRowCellPlaceholder /> : seriesTitle
|
||||
}
|
||||
</TableRowCellButton> :
|
||||
null
|
||||
}
|
||||
|
||||
<TableRowCellButton
|
||||
isDisabled={!series}
|
||||
@@ -418,6 +450,8 @@ InteractiveImportRow.propTypes = {
|
||||
language: PropTypes.object,
|
||||
size: PropTypes.number.isRequired,
|
||||
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
episodeFileId: PropTypes.number,
|
||||
isReprocessing: PropTypes.bool,
|
||||
isSelected: PropTypes.bool,
|
||||
onSelectedChange: PropTypes.func.isRequired,
|
||||
|
||||
Reference in New Issue
Block a user