1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-22 22:16:13 -04:00

Convert Manual Import to Typescript

This commit is contained in:
Mark McDowall
2023-03-27 16:48:55 -07:00
parent 032d9a720c
commit defdc84b7e
63 changed files with 2946 additions and 3528 deletions
@@ -1,568 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import SelectedMenuItem from 'Components/Menu/SelectedMenuItem';
import ConfirmModal from 'Components/Modal/ConfirmModal';
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 Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { align, icons, kinds, scrollDirections } from 'Helpers/Props';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import InteractiveImportRow from './InteractiveImportRow';
import styles from './InteractiveImportModalContent.css';
const columns = [
{
name: 'relativePath',
label: 'Relative Path',
isSortable: true,
isVisible: true
},
{
name: 'series',
label: 'Series',
isSortable: true,
isVisible: true
},
{
name: 'season',
label: 'Season',
isVisible: true
},
{
name: 'episodes',
label: 'Episode(s)',
isVisible: true
},
{
name: 'releaseGroup',
label: 'Release Group',
isVisible: true
},
{
name: 'quality',
label: 'Quality',
isSortable: true,
isVisible: true
},
{
name: 'languages',
label: 'Languages',
isSortable: true,
isVisible: true
},
{
name: 'size',
label: 'Size',
isSortable: true,
isVisible: true
},
{
name: 'customFormats',
label: React.createElement(Icon, {
name: icons.INTERACTIVE,
title: 'Custom Format'
}),
isSortable: true,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
kind: kinds.DANGER
}),
isSortable: true,
isVisible: true
}
];
const filterExistingFilesOptions = {
ALL: 'all',
NEW: 'new'
};
const importModeOptions = [
{ key: 'chooseImportMode', value: 'Choose Import Mode', disabled: true },
{ key: 'move', value: 'Move Files' },
{ key: 'copy', value: 'Hardlink/Copy Files' }
];
const SELECT = 'select';
const SERIES = 'series';
const SEASON = 'season';
const EPISODE = 'episode';
const RELEASE_GROUP = 'releaseGroup';
const QUALITY = 'quality';
const LANGUAGE = 'language';
class InteractiveImportModalContent extends Component {
//
// Lifecycle
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: [],
withoutEpisodeFileIdRowsSelected: [],
selectModalOpen: null,
columns: instanceColumns,
isConfirmDeleteModalOpen: false
};
}
componentDidUpdate(prevProps) {
const {
isDeleting,
deleteError,
onModalClose
} = this.props;
if (!isDeleting && prevProps.isDeleting && !deleteError) {
onModalClose();
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
};
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
};
onSelectedChange = ({ id, value, hasEpisodeFileId, shiftKey = false }) => {
this.setState((state) => {
return {
...toggleSelected(state, this.props.items, id, value, shiftKey),
withoutEpisodeFileIdRowsSelected: hasEpisodeFileId || !value ?
_.without(state.withoutEpisodeFileIdRowsSelected, id) :
[...state.withoutEpisodeFileIdRowsSelected, id]
};
});
};
onValidRowChange = (id, isValid) => {
this.setState((state) => {
if (isValid) {
return {
invalidRowsSelected: _.without(state.invalidRowsSelected, id)
};
}
return {
invalidRowsSelected: [...state.invalidRowsSelected, id]
};
});
};
onDeleteSelectedPress = () => {
this.setState({ isConfirmDeleteModalOpen: true });
};
onConfirmDelete = () => {
this.setState({ isConfirmDeleteModalOpen: false });
this.props.onDeleteSelectedPress(this.getSelectedIds());
};
onConfirmDeleteModalClose = () => {
this.setState({ isConfirmDeleteModalOpen: false });
};
onImportSelectedPress = () => {
const {
downloadId,
showImportMode,
importMode,
onImportSelectedPress
} = this.props;
const selected = this.getSelectedIds();
const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode;
onImportSelectedPress(selected, finalImportMode);
};
onFilterExistingFilesChange = (value) => {
this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL);
};
onImportModeChange = ({ value }) => {
this.props.onImportModeChange(value);
};
onSelectModalSelect = ({ value }) => {
this.setState({ selectModalOpen: value });
};
onSelectModalClose = () => {
this.setState({ selectModalOpen: null });
};
//
// Render
render() {
const {
downloadId,
allowSeriesChange,
autoSelectRow,
showFilterExistingFiles,
showDelete,
showImportMode,
filterExistingFiles,
title,
folder,
isFetching,
isPopulated,
error,
items,
sortKey,
sortDirection,
importMode,
interactiveImportErrorMessage,
isDeleting,
modalTitle,
onSortPress,
onModalClose
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
invalidRowsSelected,
withoutEpisodeFileIdRowsSelected,
selectModalOpen,
isConfirmDeleteModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
const orderedSelectedIds = items.reduce((acc, file) => {
if (selectedIds.includes(file.id)) {
acc.push(file.id);
}
return acc;
}, []);
const selectedItem = selectedIds.length ?
items.find((file) => file.id === selectedIds[0]) :
null;
const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
const bulkSelectOptions = [
{ key: SELECT, value: 'Select...', disabled: true },
{ key: SEASON, value: 'Select Season' },
{ key: EPISODE, value: 'Select Episode(s)' },
{ key: QUALITY, value: 'Select Quality' },
{ key: RELEASE_GROUP, value: 'Select Release Group' },
{ key: LANGUAGE, value: 'Select Language' }
];
if (allowSeriesChange) {
bulkSelectOptions.splice(1, 0, {
key: SERIES,
value: 'Select Series'
});
}
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {title || folder}
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
{
showFilterExistingFiles &&
<div className={styles.filterContainer}>
<Menu alignMenu={align.RIGHT}>
<MenuButton>
<Icon
name={icons.FILTER}
size={22}
/>
<div className={styles.filterText}>
{
filterExistingFiles ? 'Unmapped Files Only' : 'All Files'
}
</div>
</MenuButton>
<MenuContent>
<SelectedMenuItem
name={filterExistingFilesOptions.ALL}
isSelected={!filterExistingFiles}
onPress={this.onFilterExistingFilesChange}
>
All Files
</SelectedMenuItem>
<SelectedMenuItem
name={filterExistingFilesOptions.NEW}
isSelected={filterExistingFiles}
onPress={this.onFilterExistingFilesChange}
>
Unmapped Files Only
</SelectedMenuItem>
</MenuContent>
</Menu>
</div>
}
{
isFetching &&
<LoadingIndicator />
}
{
error &&
<div>{errorMessage}</div>
}
{
isPopulated && !!items.length && !isFetching && !isFetching &&
<Table
columns={this.state.columns}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveImportRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
allowSeriesChange={allowSeriesChange}
autoSelectRow={autoSelectRow}
columns={this.state.columns}
modalTitle={modalTitle}
onSelectedChange={this.onSelectedChange}
onValidRowChange={this.onValidRowChange}
/>
);
})
}
</TableBody>
</Table>
}
{
isPopulated && !items.length && !isFetching &&
'No video files were found in the selected folder'
}
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.leftButtons}>
{
showDelete ?
<SpinnerButton
className={styles.deleteButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!selectedIds.length || !!withoutEpisodeFileIdRowsSelected.length}
onPress={this.onDeleteSelectedPress}
>
Delete
</SpinnerButton> :
null
}
{
!downloadId && showImportMode ?
<SelectInput
className={styles.importMode}
name="importMode"
value={importMode}
values={importModeOptions}
onChange={this.onImportModeChange}
/> :
null
}
<SelectInput
className={styles.bulkSelect}
name="select"
value={SELECT}
values={bulkSelectOptions}
isDisabled={!selectedIds.length}
onChange={this.onSelectModalSelect}
/>
</div>
<div className={styles.rightButtons}>
<Button onPress={onModalClose}>
Cancel
</Button>
{
interactiveImportErrorMessage &&
<span className={styles.errorMessage}>{interactiveImportErrorMessage}</span>
}
<Button
kind={kinds.SUCCESS}
isDisabled={!selectedIds.length || !!invalidRowsSelected.length}
onPress={this.onImportSelectedPress}
>
Import
</Button>
</div>
</ModalFooter>
<SelectSeriesModal
isOpen={selectModalOpen === SERIES}
ids={selectedIds}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectSeasonModal
isOpen={selectModalOpen === SEASON}
ids={selectedIds}
seriesId={selectedItem && selectedItem.series && selectedItem.series.id}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectEpisodeModal
isOpen={selectModalOpen === EPISODE}
ids={orderedSelectedIds}
seriesId={selectedItem && selectedItem.series && selectedItem.series.id}
seasonNumber={selectedItem && selectedItem.seasonNumber}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === RELEASE_GROUP}
ids={selectedIds}
releaseGroup=""
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === LANGUAGE}
ids={selectedIds}
languageIds={[0]}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === QUALITY}
ids={selectedIds}
qualityId={0}
proper={false}
real={false}
modalTitle={modalTitle}
onModalClose={this.onSelectModalClose}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Selected Episode Files"
message={'Are you sure you want to delete the selected episode files?'}
confirmLabel="Delete"
onConfirm={this.onConfirmDelete}
onCancel={this.onConfirmDeleteModalClose}
/>
</ModalContent>
);
}
}
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,
importMode: PropTypes.string.isRequired,
title: PropTypes.string,
folder: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
interactiveImportErrorMessage: PropTypes.string,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
modalTitle: PropTypes.string.isRequired,
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'
};
export default InteractiveImportModalContent;
@@ -0,0 +1,873 @@
import { cloneDeep, without } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import SelectInput from 'Components/Form/SelectInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import SelectedMenuItem from 'Components/Menu/SelectedMenuItem';
import ConfirmModal from 'Components/Modal/ConfirmModal';
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 Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds, scrollDirections } from 'Helpers/Props';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import ImportMode from 'InteractiveImport/ImportMode';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import { executeCommand } from 'Store/Actions/commandActions';
import {
deleteEpisodeFiles,
updateEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearInteractiveImport,
fetchInteractiveImportItems,
reprocessInteractiveImportItems,
setInteractiveImportMode,
setInteractiveImportSort,
updateInteractiveImportItems,
} from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import InteractiveImportRow from './InteractiveImportRow';
import styles from './InteractiveImportModalContent.css';
type SelectType =
| 'select'
| 'series'
| 'season'
| 'episode'
| 'releaseGroup'
| 'quality'
| 'language';
const COLUMNS = [
{
name: 'relativePath',
label: 'Relative Path',
isSortable: true,
isVisible: true,
},
{
name: 'series',
label: 'Series',
isSortable: true,
isVisible: true,
},
{
name: 'season',
label: 'Season',
isVisible: true,
},
{
name: 'episodes',
label: 'Episode(s)',
isVisible: true,
},
{
name: 'releaseGroup',
label: 'Release Group',
isVisible: true,
},
{
name: 'quality',
label: 'Quality',
isSortable: true,
isVisible: true,
},
{
name: 'languages',
label: 'Languages',
isSortable: true,
isVisible: true,
},
{
name: 'size',
label: 'Size',
isSortable: true,
isVisible: true,
},
{
name: 'customFormats',
label: React.createElement(Icon, {
name: icons.INTERACTIVE,
title: 'Custom Format',
}),
isSortable: true,
isVisible: true,
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
kind: kinds.DANGER,
}),
isSortable: true,
isVisible: true,
},
];
const filterExistingFilesOptions = {
ALL: 'all',
NEW: 'new',
};
const importModeOptions = [
{ key: 'chooseImportMode', value: 'Choose Import Mode', disabled: true },
{ key: 'move', value: 'Move Files' },
{ key: 'copy', value: 'Hardlink/Copy Files' },
];
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;
}
return !hasDifferentItems(originalFile.episodes, episodes);
}
const episodeFilesInfoSelector = createSelector(
(state) => state.episodeFiles.isDeleting,
(state) => state.episodeFiles.deleteError,
(isDeleting, deleteError) => {
return {
isDeleting,
deleteError,
};
}
);
const importModeSelector = createSelector(
(state) => state.interactiveImport.importMode,
(importMode) => {
return importMode;
}
);
interface InteractiveImportModalContentProps {
downloadId?: string;
seriesId?: number;
seasonNumber?: number;
showSeries?: boolean;
allowSeriesChange?: boolean;
autoSelectRow?: boolean;
showDelete?: boolean;
showImportMode?: boolean;
showFilterExistingFiles?: boolean;
title?: string;
folder?: string;
sortKey?: string;
sortDirection?: string;
initialSortKey?: string;
initialSortDirection?: string;
modalTitle: string;
onModalClose(): void;
}
function InteractiveImportModalContent(
props: InteractiveImportModalContentProps
) {
const {
downloadId,
seriesId,
seasonNumber,
allowSeriesChange = true,
autoSelectRow = true,
showSeries = true,
showFilterExistingFiles = false,
showDelete = false,
showImportMode = true,
title,
folder,
initialSortKey,
initialSortDirection,
modalTitle,
onModalClose,
} = props;
const {
isFetching,
isPopulated,
error,
items,
originalItems,
sortKey,
sortDirection,
} = useSelector(createClientSideCollectionSelector('interactiveImport'));
const { isDeleting, deleteError } = useSelector(episodeFilesInfoSelector);
const importMode = useSelector(importModeSelector);
const [invalidRowsSelected, setInvalidRowsSelected] = useState([]);
const [
withoutEpisodeFileIdRowsSelected,
setWithoutEpisodeFileIdRowsSelected,
] = useState([]);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] =
useState(false);
const [filterExistingFiles, setFilterExistingFiles] = useState(false);
const [interactiveImportErrorMessage, setInteractiveImportErrorMessage] =
useState<string | null>(null);
const [selectState, setSelectState] = useSelectState();
const [bulkSelectOptions, setBulkSelectOptions] = useState([
{ key: 'select', value: 'Select...', disabled: true },
{ key: 'season', value: 'Select Season' },
{ key: 'episode', value: 'Select Episode(s)' },
{ key: 'quality', value: 'Select Quality' },
{ key: 'releaseGroup', value: 'Select Release Group' },
{ key: 'language', value: 'Select Language' },
]);
const { allSelected, allUnselected, selectedState } = selectState;
const previousIsDeleting = usePrevious(isDeleting);
const dispatch = useDispatch();
const columns: Column[] = useMemo(() => {
const result = cloneDeep(COLUMNS);
if (!showSeries) {
result.find((c) => c.name === 'series').isVisible = false;
}
return result;
}, [showSeries]);
const selectedIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
useEffect(
() => {
if (allowSeriesChange) {
const newBulkSelectOptions = [...bulkSelectOptions];
newBulkSelectOptions.splice(1, 0, {
key: 'series',
value: 'Select Series',
});
setBulkSelectOptions(newBulkSelectOptions);
}
if (initialSortKey) {
const sortProps: { sortKey: string; sortDirection?: string } = {
sortKey: initialSortKey,
};
if (initialSortDirection) {
sortProps.sortDirection = initialSortDirection;
}
dispatch(setInteractiveImportSort(sortProps));
}
dispatch(
fetchInteractiveImportItems({
downloadId,
seriesId,
seasonNumber,
folder,
filterExistingFiles,
})
);
// returned function will be called on component unmount
return () => {
dispatch(clearInteractiveImport());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
if (!isDeleting && previousIsDeleting && !deleteError) {
onModalClose();
}
}, [previousIsDeleting, isDeleting, deleteError, onModalClose]);
const onSelectAllChange = useCallback(
({ value }) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback(
({ id, value, hasEpisodeFileId, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
setWithoutEpisodeFileIdRowsSelected(
hasEpisodeFileId || !value
? without(withoutEpisodeFileIdRowsSelected, id)
: [...withoutEpisodeFileIdRowsSelected, id]
);
},
[
items,
withoutEpisodeFileIdRowsSelected,
setSelectState,
setWithoutEpisodeFileIdRowsSelected,
]
);
const onValidRowChange = useCallback(
(id: number, isValid: boolean) => {
if (isValid && invalidRowsSelected.includes(id)) {
setInvalidRowsSelected(without(invalidRowsSelected, id));
} else if (!isValid && !invalidRowsSelected.includes(id)) {
setInvalidRowsSelected([...invalidRowsSelected, id]);
}
},
[invalidRowsSelected, setInvalidRowsSelected]
);
const onDeleteSelectedPress = useCallback(() => {
setIsConfirmDeleteModalOpen(true);
}, [setIsConfirmDeleteModalOpen]);
const onConfirmDelete = useCallback(() => {
setIsConfirmDeleteModalOpen(false);
const episodeFileIds = items.reduce((acc, item) => {
if (selectedIds.indexOf(item.id) > -1 && item.episodeFileId) {
acc.push(item.episodeFileId);
}
return acc;
}, []);
dispatch(deleteEpisodeFiles({ episodeFileIds }));
}, [items, selectedIds, setIsConfirmDeleteModalOpen, dispatch]);
const onConfirmDeleteModalClose = useCallback(() => {
setIsConfirmDeleteModalOpen(false);
}, [setIsConfirmDeleteModalOpen]);
const onImportSelectedPress = useCallback(() => {
const finalImportMode =
downloadId || !showImportMode ? ImportMode.Auto : importMode;
const existingFiles = [];
const files = [];
if (finalImportMode === 'chooseImportMode') {
setInteractiveImportErrorMessage('An import mode must be selected');
return;
}
items.forEach((item) => {
const isSelected = selectedIds.indexOf(item.id) > -1;
if (isSelected) {
const {
series,
seasonNumber,
episodes,
releaseGroup,
quality,
languages,
episodeFileId,
} = item;
if (!series) {
setInteractiveImportErrorMessage(
'Series must be chosen for each selected file'
);
return;
}
if (isNaN(seasonNumber)) {
setInteractiveImportErrorMessage(
'Season must be chosen for each selected file'
);
return;
}
if (!episodes || !episodes.length) {
setInteractiveImportErrorMessage(
'One or more episodes must be chosen for each selected file'
);
return;
}
if (!quality) {
setInteractiveImportErrorMessage(
'Quality must be chosen for each selected file'
);
return;
}
if (!languages) {
setInteractiveImportErrorMessage(
'Language(s) must be chosen for each selected file'
);
return;
}
setInteractiveImportErrorMessage(null);
if (episodeFileId) {
const originalItem = originalItems.find((i) => i.id === item.id);
if (isSameEpisodeFile(item, originalItem)) {
existingFiles.push({
id: episodeFileId,
releaseGroup,
quality,
languages,
});
return;
}
}
files.push({
path: item.path,
folderName: item.folderName,
seriesId: series.id,
episodeIds: episodes.map((e) => e.id),
releaseGroup,
quality,
languages,
downloadId,
episodeFileId,
});
}
});
let shouldClose = false;
if (existingFiles.length) {
dispatch(
updateEpisodeFiles({
files: existingFiles,
})
);
shouldClose = true;
}
if (files.length) {
dispatch(
executeCommand({
name: commandNames.INTERACTIVE_IMPORT,
files,
importMode,
})
);
shouldClose = true;
}
if (shouldClose) {
onModalClose();
}
}, [
downloadId,
showImportMode,
importMode,
items,
originalItems,
selectedIds,
onModalClose,
dispatch,
]);
const onSortPress = useCallback(
(sortKey, sortDirection) => {
dispatch(setInteractiveImportSort({ sortKey, sortDirection }));
},
[dispatch]
);
const onFilterExistingFilesChange = useCallback(
(value) => {
const filter = value !== filterExistingFilesOptions.ALL;
setFilterExistingFiles(filter);
dispatch(
fetchInteractiveImportItems({
downloadId,
seriesId,
folder,
filterExistingFiles: filter,
})
);
},
[downloadId, seriesId, folder, setFilterExistingFiles, dispatch]
);
const onImportModeChange = useCallback(
({ value }) => {
dispatch(setInteractiveImportMode({ importMode: value }));
},
[dispatch]
);
const onSelectModalSelect = useCallback(
({ value }) => {
setSelectModalOpen(value);
},
[setSelectModalOpen]
);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onSeriesSelect = useCallback(
(series) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
series,
seasonNumber: undefined,
episodes: [],
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, setSelectModalOpen, dispatch]
);
const onSeasonSelect = useCallback(
(seasonNumber) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
seasonNumber,
episodes: [],
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, setSelectModalOpen, dispatch]
);
const onEpisodesSelect = useCallback(
(episodes) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
episodes,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, setSelectModalOpen, dispatch]
);
const onReleaseGroupSelect = useCallback(
(releaseGroup) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
releaseGroup,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const onLanguagesSelect = useCallback(
(newLanguages) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
languages: newLanguages,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const onQualitySelect = useCallback(
(quality) => {
dispatch(
updateInteractiveImportItems({
ids: selectedIds,
quality,
})
);
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
setSelectModalOpen(null);
},
[selectedIds, dispatch]
);
const orderedSelectedIds = items.reduce((acc, file) => {
if (selectedIds.includes(file.id)) {
acc.push(file.id);
}
return acc;
}, []);
const selectedItem = selectedIds.length
? items.find((file) => file.id === selectedIds[0])
: null;
const errorMessage = getErrorMessage(
error,
'Unable to load manual import items'
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{modalTitle} - {title || folder}
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
{showFilterExistingFiles && (
<div className={styles.filterContainer}>
<Menu alignMenu={align.RIGHT}>
<MenuButton>
<Icon name={icons.FILTER} size={22} />
<div className={styles.filterText}>
{filterExistingFiles ? 'Unmapped Files Only' : 'All Files'}
</div>
</MenuButton>
<MenuContent>
<SelectedMenuItem
name={filterExistingFilesOptions.ALL}
isSelected={!filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
All Files
</SelectedMenuItem>
<SelectedMenuItem
name={filterExistingFilesOptions.NEW}
isSelected={filterExistingFiles}
onPress={onFilterExistingFilesChange}
>
Unmapped Files Only
</SelectedMenuItem>
</MenuContent>
</Menu>
</div>
)}
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={columns}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<InteractiveImportRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
allowSeriesChange={allowSeriesChange}
autoSelectRow={autoSelectRow}
columns={columns}
modalTitle={modalTitle}
onSelectedChange={onSelectedChange}
onValidRowChange={onValidRowChange}
/>
);
})}
</TableBody>
</Table>
) : null}
{isPopulated && !items.length && !isFetching
? 'No video files were found in the selected folder'
: null}
</ModalBody>
<ModalFooter className={styles.footer}>
<div className={styles.leftButtons}>
{showDelete ? (
<SpinnerButton
className={styles.deleteButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={
!selectedIds.length || !!withoutEpisodeFileIdRowsSelected.length
}
onPress={onDeleteSelectedPress}
>
Delete
</SpinnerButton>
) : null}
{!downloadId && showImportMode ? (
<SelectInput
className={styles.importMode}
name="importMode"
value={importMode}
values={importModeOptions}
onChange={onImportModeChange}
/>
) : null}
<SelectInput
className={styles.bulkSelect}
name="select"
value={'select'}
values={bulkSelectOptions}
isDisabled={!selectedIds.length}
onChange={onSelectModalSelect}
/>
</div>
<div className={styles.rightButtons}>
<Button onPress={onModalClose}>Cancel</Button>
{interactiveImportErrorMessage && (
<span className={styles.errorMessage}>
{interactiveImportErrorMessage}
</span>
)}
<Button
kind={kinds.SUCCESS}
isDisabled={!selectedIds.length || !!invalidRowsSelected.length}
onPress={onImportSelectedPress}
>
Import
</Button>
</div>
</ModalFooter>
<SelectSeriesModal
isOpen={selectModalOpen === 'series'}
modalTitle={modalTitle}
onSeriesSelect={onSeriesSelect}
onModalClose={onSelectModalClose}
/>
<SelectSeasonModal
isOpen={selectModalOpen === 'season'}
seriesId={selectedItem?.series?.id}
modalTitle={modalTitle}
onSeasonSelect={onSeasonSelect}
onModalClose={onSelectModalClose}
/>
<SelectEpisodeModal
isOpen={selectModalOpen === 'episode'}
selectedIds={orderedSelectedIds}
seriesId={selectedItem?.series?.id}
seasonNumber={selectedItem?.seasonNumber}
isAnime={selectedItem?.series.type === 'anime'}
modalTitle={modalTitle}
onEpisodesSelect={onEpisodesSelect}
onModalClose={onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === 'releaseGroup'}
releaseGroup=""
modalTitle={modalTitle}
onReleaseGroupSelect={onReleaseGroupSelect}
onModalClose={onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === 'language'}
languageIds={[0]}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === 'quality'}
qualityId={0}
proper={false}
real={false}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onSelectModalClose}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Selected Episode Files"
message={'Are you sure you want to delete the selected episode files?'}
confirmLabel="Delete"
onConfirm={onConfirmDelete}
onCancel={onConfirmDeleteModalClose}
/>
</ModalContent>
);
}
export default InteractiveImportModalContent;
@@ -1,327 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { sortDirections } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { deleteEpisodeFiles, updateEpisodeFiles } from 'Store/Actions/episodeFileActions';
import { clearInteractiveImport, fetchInteractiveImportItems, setInteractiveImportMode, setInteractiveImportSort } from 'Store/Actions/interactiveImportActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
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;
}
return !hasDifferentItems(originalFile.episodes, episodes);
}
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('interactiveImport'),
(state) => state.episodeFiles.isDeleting,
(state) => state.episodeFiles.deleteError,
(interactiveImport, isDeleting, deleteError) => {
return {
...interactiveImport,
isDeleting,
deleteError
};
}
);
}
const mapDispatchToProps = {
dispatchFetchInteractiveImportItems: fetchInteractiveImportItems,
dispatchSetInteractiveImportSort: setInteractiveImportSort,
dispatchSetInteractiveImportMode: setInteractiveImportMode,
dispatchClearInteractiveImport: clearInteractiveImport,
dispatchUpdateEpisodeFiles: updateEpisodeFiles,
dispatchDeleteEpisodeFiles: deleteEpisodeFiles,
dispatchExecuteCommand: executeCommand
};
class InteractiveImportModalContentConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
interactiveImportErrorMessage: null,
filterExistingFiles: true
};
}
componentDidMount() {
const {
downloadId,
seriesId,
seasonNumber,
folder,
initialSortKey,
initialSortDirection,
dispatchSetInteractiveImportSort,
dispatchFetchInteractiveImportItems
} = this.props;
const {
filterExistingFiles
} = this.state;
if (initialSortKey) {
const sortProps = {
sortKey: initialSortKey
};
if (initialSortDirection) {
sortProps.sortDirection = initialSortDirection;
}
dispatchSetInteractiveImportSort(sortProps);
}
dispatchFetchInteractiveImportItems({
downloadId,
seriesId,
seasonNumber,
folder,
filterExistingFiles
});
}
componentDidUpdate(prevProps, prevState) {
const {
filterExistingFiles
} = this.state;
if (prevState.filterExistingFiles !== filterExistingFiles) {
const {
downloadId,
seriesId,
folder
} = this.props;
this.props.dispatchFetchInteractiveImportItems({
downloadId,
seriesId,
folder,
filterExistingFiles
});
}
}
componentWillUnmount() {
this.props.dispatchClearInteractiveImport();
}
//
// Listeners
onSortPress = (sortKey, sortDirection) => {
this.props.dispatchSetInteractiveImportSort({ sortKey, sortDirection });
};
onFilterExistingFilesChange = (filterExistingFiles) => {
this.setState({ filterExistingFiles });
};
onImportModeChange = (importMode) => {
this.props.dispatchSetInteractiveImportMode({ importMode });
};
onDeleteSelectedPress = (selected) => {
const {
items,
dispatchDeleteEpisodeFiles
} = this.props;
const episodeFileIds = items.reduce((acc, item) => {
if (selected.indexOf(item.id) > -1 && item.episodeFileId) {
acc.push(item.episodeFileId);
}
return acc;
}, []);
dispatchDeleteEpisodeFiles({ episodeFileIds });
};
onImportSelectedPress = (selected, importMode) => {
const {
items,
originalItems,
dispatchUpdateEpisodeFiles,
dispatchExecuteCommand,
onModalClose
} = this.props;
const existingFiles = [];
const files = [];
if (importMode === 'chooseImportMode') {
this.setState({ interactiveImportErrorMessage: 'An import mode must be selected' });
return;
}
items.forEach((item) => {
const isSelected = selected.indexOf(item.id) > -1;
if (isSelected) {
const {
series,
seasonNumber,
episodes,
releaseGroup,
quality,
languages,
episodeFileId
} = item;
if (!series) {
this.setState({ interactiveImportErrorMessage: 'Series must be chosen for each selected file' });
return;
}
if (isNaN(seasonNumber)) {
this.setState({ interactiveImportErrorMessage: 'Season must be chosen for each selected file' });
return;
}
if (!episodes || !episodes.length) {
this.setState({ interactiveImportErrorMessage: 'One or more episodes must be chosen for each selected file' });
return;
}
if (!quality) {
this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' });
return;
}
if (!languages) {
this.setState({ interactiveImportErrorMessage: 'Language(s) must be chosen for each selected file' });
return;
}
if (episodeFileId) {
const originalItem = originalItems.find((i) => i.id === item.id);
if (isSameEpisodeFile(item, originalItem)) {
existingFiles.push({
id: episodeFileId,
releaseGroup,
quality,
languages
});
return;
}
}
files.push({
path: item.path,
folderName: item.folderName,
seriesId: series.id,
episodeIds: episodes.map((e) => e.id),
releaseGroup,
quality,
languages,
downloadId: this.props.downloadId,
episodeFileId
});
}
});
let shouldClose = false;
if (existingFiles.length) {
dispatchUpdateEpisodeFiles({
files: existingFiles
});
shouldClose = true;
}
if (files.length) {
dispatchExecuteCommand({
name: commandNames.INTERACTIVE_IMPORT,
files,
importMode
});
shouldClose = true;
}
if (shouldClose) {
onModalClose();
}
};
//
// Render
render() {
const {
interactiveImportErrorMessage,
filterExistingFiles
} = this.state;
return (
<InteractiveImportModalContent
{...this.props}
interactiveImportErrorMessage={interactiveImportErrorMessage}
filterExistingFiles={filterExistingFiles}
onSortPress={this.onSortPress}
onFilterExistingFilesChange={this.onFilterExistingFilesChange}
onImportModeChange={this.onImportModeChange}
onDeleteSelectedPress={this.onDeleteSelectedPress}
onImportSelectedPress={this.onImportSelectedPress}
/>
);
}
}
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
};
InteractiveImportModalContentConnector.defaultProps = {
filterExistingFiles: true
};
export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector);
@@ -1,504 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import formatBytes from 'Utilities/Number/formatBytes';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
import styles from './InteractiveImportRow.css';
class InteractiveImportRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isSelectSeriesModalOpen: false,
isSelectSeasonModalOpen: false,
isSelectEpisodeModalOpen: false,
isSelectReleaseGroupModalOpen: false,
isSelectQualityModalOpen: false,
isSelectLanguageModalOpen: false
};
}
componentDidMount() {
const {
allowSeriesChange,
id,
series,
seasonNumber,
episodes,
quality,
languages,
episodeFileId,
columns
} = this.props;
if (
allowSeriesChange &&
series &&
seasonNumber != null &&
episodes.length &&
quality &&
languages
) {
this.props.onSelectedChange({
id,
hasEpisodeFileId: !!episodeFileId,
value: true
});
}
this.setState({
isSeriesColumnVisible: columns.find((c) => c.name === 'series').isVisible
});
}
componentDidUpdate(prevProps) {
const {
id,
series,
seasonNumber,
episodes,
quality,
languages,
isSelected,
onValidRowChange
} = this.props;
if (
prevProps.series === series &&
prevProps.seasonNumber === seasonNumber &&
!hasDifferentItems(prevProps.episodes, episodes) &&
prevProps.quality === quality &&
prevProps.languages === languages &&
prevProps.isSelected === isSelected
) {
return;
}
const isValid = !!(
series &&
seasonNumber != null &&
episodes.length &&
quality &&
languages
);
if (isSelected && !isValid) {
onValidRowChange(id, false);
} else {
onValidRowChange(id, true);
}
}
//
// Control
selectRowAfterChange = (value) => {
const {
id,
episodeFileId,
isSelected
} = this.props;
if (!isSelected && value === true) {
this.props.onSelectedChange({
id,
hasEpisodeFileId: !!episodeFileId,
value
});
}
};
//
// Listeners
onSelectedChange = (result) => {
const {
episodeFileId,
onSelectedChange
} = this.props;
onSelectedChange({
...result,
hasEpisodeFileId: !!episodeFileId
});
};
onSelectSeriesPress = () => {
this.setState({ isSelectSeriesModalOpen: true });
};
onSelectSeasonPress = () => {
this.setState({ isSelectSeasonModalOpen: true });
};
onSelectEpisodePress = () => {
this.setState({ isSelectEpisodeModalOpen: true });
};
onSelectReleaseGroupPress = () => {
this.setState({ isSelectReleaseGroupModalOpen: true });
};
onSelectQualityPress = () => {
this.setState({ isSelectQualityModalOpen: true });
};
onSelectLanguagePress = () => {
this.setState({ isSelectLanguageModalOpen: true });
};
onSelectSeriesModalClose = (changed) => {
this.setState({ isSelectSeriesModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectSeasonModalClose = (changed) => {
this.setState({ isSelectSeasonModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectEpisodeModalClose = (changed) => {
this.setState({ isSelectEpisodeModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectReleaseGroupModalClose = (changed) => {
this.setState({ isSelectReleaseGroupModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectQualityModalClose = (changed) => {
this.setState({ isSelectQualityModalOpen: false });
this.selectRowAfterChange(changed);
};
onSelectLanguageModalClose = (changed) => {
this.setState({ isSelectLanguageModalOpen: false });
this.selectRowAfterChange(changed);
};
//
// Render
render() {
const {
id,
allowSeriesChange,
relativePath,
series,
seasonNumber,
episodes,
quality,
languages,
releaseGroup,
size,
customFormats,
rejections,
isReprocessing,
isSelected,
modalTitle
} = this.props;
const {
isSelectSeriesModalOpen,
isSelectSeasonModalOpen,
isSelectEpisodeModalOpen,
isSelectReleaseGroupModalOpen,
isSelectQualityModalOpen,
isSelectLanguageModalOpen
} = this.state;
const seriesTitle = series ? series.title : '';
const isAnime = series ? series.seriesType === 'anime' : false;
const episodeInfo = episodes.map((episode) => {
return (
<div key={episode.id}>
{episode.episodeNumber}
{
isAnime && episode.absoluteEpisodeNumber != null ?
` (${episode.absoluteEpisodeNumber})` :
''
}
{` - ${episode.title}`}
</div>
);
});
const showSeriesPlaceholder = isSelected && !series;
const showSeasonNumberPlaceholder = isSelected && !!series && isNaN(seasonNumber) && !isReprocessing;
const showEpisodeNumbersPlaceholder = isSelected && Number.isInteger(seasonNumber) && !episodes.length;
const showReleaseGroupPlaceholder = isSelected && !releaseGroup;
const showQualityPlaceholder = isSelected && !quality;
const showLanguagePlaceholder = isSelected && !languages;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={this.onSelectedChange}
/>
<TableRowCell
className={styles.relativePath}
title={relativePath}
>
{relativePath}
</TableRowCell>
{
this.state.isSeriesColumnVisible ?
<TableRowCellButton
isDisabled={!allowSeriesChange}
title={allowSeriesChange ? 'Click to change series' : undefined}
onPress={this.onSelectSeriesPress}
>
{
showSeriesPlaceholder ? <InteractiveImportRowCellPlaceholder /> : seriesTitle
}
</TableRowCellButton> :
null
}
<TableRowCellButton
isDisabled={!series}
title={series ? 'Click to change season' : undefined}
onPress={this.onSelectSeasonPress}
>
{
showSeasonNumberPlaceholder ? <InteractiveImportRowCellPlaceholder /> : seasonNumber
}
{
isReprocessing && seasonNumber == null ?
<LoadingIndicator className={styles.reprocessing}
size={20}
/> : null
}
</TableRowCellButton>
<TableRowCellButton
isDisabled={!series || isNaN(seasonNumber)}
title={series && !isNaN(seasonNumber) ? 'Click to change episode' : undefined}
onPress={this.onSelectEpisodePress}
>
{
showEpisodeNumbersPlaceholder ? <InteractiveImportRowCellPlaceholder /> : episodeInfo
}
</TableRowCellButton>
<TableRowCellButton
title="Click to change release group"
onPress={this.onSelectReleaseGroupPress}
>
{
showReleaseGroupPlaceholder ?
<InteractiveImportRowCellPlaceholder
isOptional={true}
/> :
releaseGroup
}
</TableRowCellButton>
<TableRowCellButton
className={styles.quality}
title="Click to change quality"
onPress={this.onSelectQualityPress}
>
{
showQualityPlaceholder &&
<InteractiveImportRowCellPlaceholder />
}
{
!showQualityPlaceholder && !!quality &&
<EpisodeQuality
className={styles.label}
quality={quality}
/>
}
</TableRowCellButton>
<TableRowCellButton
className={styles.language}
title="Click to change language"
onPress={this.onSelectLanguagePress}
>
{
showLanguagePlaceholder &&
<InteractiveImportRowCellPlaceholder />
}
{
!showLanguagePlaceholder && !!languages &&
<EpisodeLanguages
className={styles.label}
languages={languages}
/>
}
</TableRowCellButton>
<TableRowCell>
{formatBytes(size)}
</TableRowCell>
<TableRowCell>
{
customFormats?.length ?
<Popover
anchor={
<Icon name={icons.INTERACTIVE} />
}
title="Formats"
body={
<div className={styles.customFormatTooltip}>
<EpisodeFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/> :
null
}
</TableRowCell>
<TableRowCell>
{
rejections.length ?
<Popover
anchor={
<Icon
name={icons.DANGER}
kind={kinds.DANGER}
/>
}
title="Release Rejected"
body={
<ul>
{
rejections.map((rejection, index) => {
return (
<li key={index}>
{rejection.reason}
</li>
);
})
}
</ul>
}
position={tooltipPositions.LEFT}
canFlip={false}
/> :
null
}
</TableRowCell>
<SelectSeriesModal
isOpen={isSelectSeriesModalOpen}
ids={[id]}
modalTitle={modalTitle}
onModalClose={this.onSelectSeriesModalClose}
/>
<SelectSeasonModal
isOpen={isSelectSeasonModalOpen}
ids={[id]}
seriesId={series && series.id}
modalTitle={modalTitle}
onModalClose={this.onSelectSeasonModalClose}
/>
<SelectEpisodeModal
isOpen={isSelectEpisodeModalOpen}
ids={[id]}
seriesId={series && series.id}
isAnime={isAnime}
seasonNumber={seasonNumber}
relativePath={relativePath}
modalTitle={modalTitle}
onModalClose={this.onSelectEpisodeModalClose}
/>
<SelectReleaseGroupModal
isOpen={isSelectReleaseGroupModalOpen}
ids={[id]}
releaseGroup={releaseGroup ?? ''}
modalTitle={modalTitle}
onModalClose={this.onSelectReleaseGroupModalClose}
/>
<SelectQualityModal
isOpen={isSelectQualityModalOpen}
ids={[id]}
qualityId={quality ? quality.quality.id : 0}
proper={quality ? quality.revision.version > 1 : false}
real={quality ? quality.revision.real > 0 : false}
modalTitle={modalTitle}
onModalClose={this.onSelectQualityModalClose}
/>
<SelectLanguageModal
isOpen={isSelectLanguageModalOpen}
ids={[id]}
languageIds={languages ? languages.map((l) => l.id) : []}
modalTitle={modalTitle}
onModalClose={this.onSelectLanguageModalClose}
/>
</TableRow>
);
}
}
InteractiveImportRow.propTypes = {
id: PropTypes.number.isRequired,
allowSeriesChange: PropTypes.bool.isRequired,
relativePath: PropTypes.string.isRequired,
series: PropTypes.object,
seasonNumber: PropTypes.number,
episodes: PropTypes.arrayOf(PropTypes.object).isRequired,
releaseGroup: PropTypes.string,
quality: PropTypes.object,
languages: PropTypes.arrayOf(PropTypes.object),
size: PropTypes.number.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
episodeFileId: PropTypes.number,
isReprocessing: PropTypes.bool,
isSelected: PropTypes.bool,
modalTitle: PropTypes.string.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onValidRowChange: PropTypes.func.isRequired
};
InteractiveImportRow.defaultProps = {
episodes: []
};
export default InteractiveImportRow;
@@ -0,0 +1,506 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import Episode from 'Episode/Episode';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
import { SelectedEpisode } from 'InteractiveImport/Episode/SelectEpisodeModalContent';
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import {
reprocessInteractiveImportItems,
updateInteractiveImportItem,
} from 'Store/Actions/interactiveImportActions';
import Rejection from 'typings/Rejection';
import formatBytes from 'Utilities/Number/formatBytes';
import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder';
import styles from './InteractiveImportRow.css';
type SelectType =
| 'series'
| 'season'
| 'episode'
| 'releaseGroup'
| 'quality'
| 'language';
interface InteractiveImportRowProps {
id: number;
allowSeriesChange: boolean;
relativePath: string;
series?: Series;
seasonNumber?: number;
episodes?: Episode[];
releaseGroup?: string;
quality?: QualityModel;
languages?: Language[];
size: number;
customFormats?: object[];
rejections: Rejection[];
columns: Column[];
episodeFileId?: number;
isReprocessing?: boolean;
isSelected?: boolean;
modalTitle: string;
onSelectedChange(...args: unknown[]): void;
onValidRowChange(id: number, isValid: boolean): void;
}
function InteractiveImportRow(props: InteractiveImportRowProps) {
const {
id,
allowSeriesChange,
relativePath,
series,
seasonNumber,
episodes = [],
quality,
languages,
releaseGroup,
size,
customFormats,
rejections,
isReprocessing,
isSelected,
modalTitle,
episodeFileId,
columns,
onSelectedChange,
onValidRowChange,
} = props;
const dispatch = useDispatch();
const isSeriesColumnVisible = useMemo(
() => columns.find((c) => c.name === 'series').isVisible,
[columns]
);
const [selectModalOpen, setSelectModalOpen] = useState<SelectType | null>(
null
);
useEffect(
() => {
if (
allowSeriesChange &&
series &&
seasonNumber != null &&
episodes.length &&
quality &&
languages
) {
onSelectedChange({
id,
hasEpisodeFileId: !!episodeFileId,
value: true,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
const isValid = !!(
series &&
seasonNumber != null &&
episodes.length &&
quality &&
languages
);
if (isSelected && !isValid) {
onValidRowChange(id, false);
} else {
onValidRowChange(id, true);
}
}, [
id,
series,
seasonNumber,
episodes,
quality,
languages,
isSelected,
onValidRowChange,
]);
const onSelectedChangeWrapper = useCallback(
(result) => {
onSelectedChange({
...result,
hasEpisodeFileId: !!episodeFileId,
});
},
[episodeFileId, onSelectedChange]
);
const selectRowAfterChange = useCallback(() => {
if (!isSelected) {
onSelectedChange({
id,
hasEpisodeFileId: !!episodeFileId,
value: true,
});
}
}, [id, episodeFileId, isSelected, onSelectedChange]);
const onSelectModalClose = useCallback(() => {
setSelectModalOpen(null);
}, [setSelectModalOpen]);
const onSelectSeriesPress = useCallback(() => {
setSelectModalOpen('series');
}, [setSelectModalOpen]);
const onSeriesSelect = useCallback(
(series: Series) => {
dispatch(
updateInteractiveImportItem({
id,
series,
seasonNumber: undefined,
episodes: [],
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectSeasonPress = useCallback(() => {
setSelectModalOpen('season');
}, [setSelectModalOpen]);
const onSeasonSelect = useCallback(
(seasonNumber: number) => {
dispatch(
updateInteractiveImportItem({
id,
seasonNumber,
episodes: [],
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectEpisodePress = useCallback(() => {
setSelectModalOpen('episode');
}, [setSelectModalOpen]);
const onEpisodesSelect = useCallback(
(selectedEpisodes: SelectedEpisode[]) => {
dispatch(
updateInteractiveImportItem({
id,
episodes: selectedEpisodes[0].episodes,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectReleaseGroupPress = useCallback(() => {
setSelectModalOpen('releaseGroup');
}, [setSelectModalOpen]);
const onReleaseGroupSelect = useCallback(
(releaseGroup: string) => {
dispatch(
updateInteractiveImportItem({
id,
releaseGroup,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectQualityPress = useCallback(() => {
setSelectModalOpen('quality');
}, [setSelectModalOpen]);
const onQualitySelect = useCallback(
(quality: QualityModel) => {
dispatch(
updateInteractiveImportItem({
id,
quality,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const onSelectLanguagePress = useCallback(() => {
setSelectModalOpen('language');
}, [setSelectModalOpen]);
const onLanguagesSelect = useCallback(
(languages: Language[]) => {
dispatch(
updateInteractiveImportItem({
id,
languages,
})
);
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
setSelectModalOpen(null);
selectRowAfterChange();
},
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
);
const seriesTitle = series ? series.title : '';
const isAnime = series?.seriesType === 'anime';
const episodeInfo = episodes.map((episode) => {
return (
<div key={episode.id}>
{episode.episodeNumber}
{isAnime && episode.absoluteEpisodeNumber != null
? ` (${episode.absoluteEpisodeNumber})`
: ''}
{` - ${episode.title}`}
</div>
);
});
const showSeriesPlaceholder = isSelected && !series;
const showSeasonNumberPlaceholder =
isSelected && !!series && isNaN(seasonNumber) && !isReprocessing;
const showEpisodeNumbersPlaceholder =
isSelected && Number.isInteger(seasonNumber) && !episodes.length;
const showReleaseGroupPlaceholder = isSelected && !releaseGroup;
const showQualityPlaceholder = isSelected && !quality;
const showLanguagePlaceholder = isSelected && !languages;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.relativePath} title={relativePath}>
{relativePath}
</TableRowCell>
{isSeriesColumnVisible ? (
<TableRowCellButton
isDisabled={!allowSeriesChange}
title={allowSeriesChange ? 'Click to change series' : undefined}
onPress={onSelectSeriesPress}
>
{showSeriesPlaceholder ? (
<InteractiveImportRowCellPlaceholder />
) : (
seriesTitle
)}
</TableRowCellButton>
) : null}
<TableRowCellButton
isDisabled={!series}
title={series ? 'Click to change season' : undefined}
onPress={onSelectSeasonPress}
>
{showSeasonNumberPlaceholder ? (
<InteractiveImportRowCellPlaceholder />
) : (
seasonNumber
)}
{isReprocessing && seasonNumber == null ? (
<LoadingIndicator className={styles.reprocessing} size={20} />
) : null}
</TableRowCellButton>
<TableRowCellButton
isDisabled={!series || isNaN(seasonNumber)}
title={
series && !isNaN(seasonNumber) ? 'Click to change episode' : undefined
}
onPress={onSelectEpisodePress}
>
{showEpisodeNumbersPlaceholder ? (
<InteractiveImportRowCellPlaceholder />
) : (
episodeInfo
)}
</TableRowCellButton>
<TableRowCellButton
title="Click to change release group"
onPress={onSelectReleaseGroupPress}
>
{showReleaseGroupPlaceholder ? (
<InteractiveImportRowCellPlaceholder isOptional={true} />
) : (
releaseGroup
)}
</TableRowCellButton>
<TableRowCellButton
className={styles.quality}
title="Click to change quality"
onPress={onSelectQualityPress}
>
{showQualityPlaceholder && <InteractiveImportRowCellPlaceholder />}
{!showQualityPlaceholder && !!quality && (
<EpisodeQuality className={styles.label} quality={quality} />
)}
</TableRowCellButton>
<TableRowCellButton
className={styles.languages}
title="Click to change language"
onPress={onSelectLanguagePress}
>
{showLanguagePlaceholder && <InteractiveImportRowCellPlaceholder />}
{!showLanguagePlaceholder && !!languages && (
<EpisodeLanguages className={styles.label} languages={languages} />
)}
</TableRowCellButton>
<TableRowCell>{formatBytes(size)}</TableRowCell>
<TableRowCell>
{customFormats?.length ? (
<Popover
anchor={<Icon name={icons.INTERACTIVE} />}
title="Formats"
body={
<div className={styles.customFormatTooltip}>
<EpisodeFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/>
) : null}
</TableRowCell>
<TableRowCell>
{rejections.length ? (
<Popover
anchor={<Icon name={icons.DANGER} kind={kinds.DANGER} />}
title="Release Rejected"
body={
<ul>
{rejections.map((rejection, index) => {
return <li key={index}>{rejection.reason}</li>;
})}
</ul>
}
position={tooltipPositions.LEFT}
canFlip={false}
/>
) : null}
</TableRowCell>
<SelectSeriesModal
isOpen={selectModalOpen === 'series'}
modalTitle={modalTitle}
onSeriesSelect={onSeriesSelect}
onModalClose={onSelectModalClose}
/>
<SelectSeasonModal
isOpen={selectModalOpen === 'season'}
seriesId={series && series.id}
modalTitle={modalTitle}
onSeasonSelect={onSeasonSelect}
onModalClose={onSelectModalClose}
/>
<SelectEpisodeModal
isOpen={selectModalOpen === 'episode'}
selectedIds={[id]}
seriesId={series && series.id}
isAnime={isAnime}
seasonNumber={seasonNumber}
selectedDetails={relativePath}
modalTitle={modalTitle}
onEpisodesSelect={onEpisodesSelect}
onModalClose={onSelectModalClose}
/>
<SelectReleaseGroupModal
isOpen={selectModalOpen === 'releaseGroup'}
releaseGroup={releaseGroup ?? ''}
modalTitle={modalTitle}
onReleaseGroupSelect={onReleaseGroupSelect}
onModalClose={onSelectModalClose}
/>
<SelectQualityModal
isOpen={selectModalOpen === 'quality'}
qualityId={quality ? quality.quality.id : 0}
proper={quality ? quality.revision.version > 1 : false}
real={quality ? quality.revision.real > 0 : false}
modalTitle={modalTitle}
onQualitySelect={onQualitySelect}
onModalClose={onSelectModalClose}
/>
<SelectLanguageModal
isOpen={selectModalOpen === 'language'}
languageIds={languages ? languages.map((l) => l.id) : []}
modalTitle={modalTitle}
onLanguagesSelect={onLanguagesSelect}
onModalClose={onSelectModalClose}
/>
</TableRow>
);
}
export default InteractiveImportRow;