import { cloneDeep, without } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; 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 { EpisodeFile } from 'EpisodeFile/EpisodeFile'; 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 { SelectedEpisode } from 'InteractiveImport/Episode/SelectEpisodeModalContent'; import ImportMode from 'InteractiveImport/ImportMode'; import InteractiveImport, { InteractiveImportCommandOptions, } from 'InteractiveImport/InteractiveImport'; 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 { 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 { SortCallback } from 'typings/callbacks'; import { SelectStateInputProps } from 'typings/props'; 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'; type FilterExistingFiles = 'all' | 'new'; // TODO: This feels janky to do, but not sure of a better way currently type OnSelectedChangeCallback = React.ComponentProps< typeof InteractiveImportRow >['onSelectedChange']; 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 importModeOptions = [ { key: 'chooseImportMode', value: 'Choose Import Mode', disabled: true }, { key: 'move', value: 'Move Files' }, { key: 'copy', value: 'Hardlink/Copy Files' }, ]; function isSameEpisodeFile( file: InteractiveImport, originalFile?: InteractiveImport ) { 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: AppState) => state.episodeFiles.isDeleting, (state: AppState) => state.episodeFiles.deleteError, (isDeleting, deleteError) => { return { isDeleting, deleteError, }; } ); const importModeSelector = createSelector( (state: AppState) => state.interactiveImport.importMode, (importMode) => { return importMode; } ); interface InteractiveImportModalContentProps { downloadId?: string; seriesId?: number; seasonNumber?: number; showSeries?: boolean; allowSeriesChange?: 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, showSeries = true, showFilterExistingFiles = false, showDelete = false, showImportMode = true, title, folder, initialSortKey, initialSortDirection, modalTitle, onModalClose, } = props; const { isFetching, isPopulated, error, items, originalItems, sortKey, sortDirection, }: InteractiveImportAppState = useSelector( createClientSideCollectionSelector('interactiveImport') ); const { isDeleting, deleteError } = useSelector(episodeFilesInfoSelector); const importMode = useSelector(importModeSelector); const [invalidRowsSelected, setInvalidRowsSelected] = useState([]); const [ withoutEpisodeFileIdRowsSelected, setWithoutEpisodeFileIdRowsSelected, ] = useState([]); const [selectModalOpen, setSelectModalOpen] = useState( null ); const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] = useState(false); const [filterExistingFiles, setFilterExistingFiles] = useState(false); const [interactiveImportErrorMessage, setInteractiveImportErrorMessage] = useState(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: Column[] = cloneDeep(COLUMNS); if (!showSeries) { const seriesColumn = result.find((c) => c.name === 'series'); if (seriesColumn) { seriesColumn.isVisible = false; } } return result; }, [showSeries]); const selectedIds: number[] = 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 }: SelectStateInputProps) => { 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: number[], 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 ? 'auto' : importMode; const existingFiles: Partial[] = []; const files: InteractiveImportCommandOptions[] = []; 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: finalImportMode, }) ); 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: FilterExistingFiles) => void >( (value) => { const filter = value !== 'all'; setFilterExistingFiles(filter); dispatch( fetchInteractiveImportItems({ downloadId, seriesId, folder, filterExistingFiles: filter, }) ); }, [downloadId, seriesId, folder, setFilterExistingFiles, dispatch] ); const onImportModeChange = useCallback< ({ value }: { value: ImportMode }) => void >( ({ value }) => { dispatch(setInteractiveImportMode({ importMode: value })); }, [dispatch] ); const onSelectModalSelect = useCallback< ({ value }: { value: SelectType }) => void >( ({ value }) => { setSelectModalOpen(value); }, [setSelectModalOpen] ); const onSelectModalClose = useCallback(() => { setSelectModalOpen(null); }, [setSelectModalOpen]); const onSeriesSelect = useCallback( (series: Series) => { dispatch( updateInteractiveImportItems({ ids: selectedIds, series, seasonNumber: undefined, episodes: [], }) ); dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); setSelectModalOpen(null); }, [selectedIds, setSelectModalOpen, dispatch] ); const onSeasonSelect = useCallback( (seasonNumber: number) => { dispatch( updateInteractiveImportItems({ ids: selectedIds, seasonNumber, episodes: [], }) ); dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); setSelectModalOpen(null); }, [selectedIds, setSelectModalOpen, dispatch] ); const onEpisodesSelect = useCallback( (episodes: SelectedEpisode[]) => { dispatch( updateInteractiveImportItems({ ids: selectedIds, episodes, }) ); dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); setSelectModalOpen(null); }, [selectedIds, setSelectModalOpen, dispatch] ); const onReleaseGroupSelect = useCallback( (releaseGroup: string) => { dispatch( updateInteractiveImportItems({ ids: selectedIds, releaseGroup, }) ); dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); setSelectModalOpen(null); }, [selectedIds, dispatch] ); const onLanguagesSelect = useCallback( (newLanguages: Language[]) => { dispatch( updateInteractiveImportItems({ ids: selectedIds, languages: newLanguages, }) ); dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); setSelectModalOpen(null); }, [selectedIds, dispatch] ); const onQualitySelect = useCallback( (quality: QualityModel) => { dispatch( updateInteractiveImportItems({ ids: selectedIds, quality, }) ); dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); setSelectModalOpen(null); }, [selectedIds, dispatch] ); const orderedSelectedIds = items.reduce((acc: number[], 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 ( {modalTitle} - {title || folder} {showFilterExistingFiles && (
{filterExistingFiles ? 'Unmapped Files Only' : 'All Files'}
All Files Unmapped Files Only
)} {isFetching ? : null} {error ?
{errorMessage}
: null} {isPopulated && !!items.length && !isFetching && !isFetching ? ( {items.map((item) => { return ( ); })}
) : null} {isPopulated && !items.length && !isFetching ? 'No video files were found in the selected folder' : null}
{showDelete ? ( Delete ) : null} {!downloadId && showImportMode ? ( ) : null}
{interactiveImportErrorMessage && ( {interactiveImportErrorMessage} )}
); } export default InteractiveImportModalContent;