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 usePrevious from 'Helpers/Hooks/usePrevious'; import useSelectState from 'Helpers/Hooks/useSelectState'; import { align, icons, kinds, scrollDirections } from 'Helpers/Props'; import ImportMode from 'InteractiveImport/ImportMode'; import InteractiveImport, { InteractiveImportCommandOptions, } from 'InteractiveImport/InteractiveImport'; import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; import SelectMovieModal from 'InteractiveImport/Movie/SelectMovieModal'; import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; import Language from 'Language/Language'; import Movie from 'Movie/Movie'; import { MovieFile } from 'MovieFile/MovieFile'; import { QualityModel } from 'Quality/Quality'; import { executeCommand } from 'Store/Actions/commandActions'; import { clearInteractiveImport, fetchInteractiveImportItems, reprocessInteractiveImportItems, setInteractiveImportMode, setInteractiveImportSort, updateInteractiveImportItems, } from 'Store/Actions/interactiveImportActions'; import { deleteMovieFiles, updateMovieFiles, } from 'Store/Actions/movieFileActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SortCallback } from 'typings/callbacks'; import { SelectStateInputProps } from 'typings/props'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import InteractiveImportRow from './InteractiveImportRow'; import styles from './InteractiveImportModalContent.css'; type SelectType = 'select' | 'movie' | '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', get label() { return translate('RelativePath'); }, isSortable: true, isVisible: true, }, { name: 'movie', get label() { return translate('Movie'); }, isSortable: true, isVisible: true, }, { name: 'releaseGroup', get label() { return translate('ReleaseGroup'); }, isVisible: true, }, { name: 'quality', get label() { return translate('Quality'); }, isSortable: true, isVisible: true, }, { name: 'languages', get label() { return translate('Languages'); }, isSortable: true, isVisible: true, }, { name: 'size', get label() { return translate('Size'); }, isSortable: true, isVisible: true, }, { name: 'customFormats', label: React.createElement(Icon, { name: icons.INTERACTIVE, get title() { return translate('CustomFormat'); }, }), isSortable: true, isVisible: true, }, { name: 'rejections', label: React.createElement(Icon, { name: icons.DANGER, kind: kinds.DANGER, }), isSortable: true, isVisible: true, }, ]; const importModeOptions = [ { key: 'chooseImportMode', get value() { return translate('ChooseImportMode'); }, disabled: true, }, { key: 'move', get value() { return translate('MoveFiles'); }, }, { key: 'copy', get value() { return translate('HardlinkCopyFiles'); }, }, ]; function isSameMovieFile( file: InteractiveImport, originalFile?: InteractiveImport ) { const { movie } = file; if (!originalFile) { return false; } if (!originalFile.movie || movie?.id !== originalFile.movie.id) { return false; } return true; } const movieFilesInfoSelector = createSelector( (state: AppState) => state.movieFiles.isDeleting, (state: AppState) => state.movieFiles.deleteError, (isDeleting, deleteError) => { return { isDeleting, deleteError, }; } ); const importModeSelector = createSelector( (state: AppState) => state.interactiveImport.importMode, (importMode) => { return importMode; } ); interface InteractiveImportModalContentProps { downloadId?: string; movieId?: number; seasonNumber?: number; showMovie?: boolean; allowMovieChange?: 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, movieId, seasonNumber, allowMovieChange = true, showMovie = 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(movieFilesInfoSelector); const importMode = useSelector(importModeSelector); const [invalidRowsSelected, setInvalidRowsSelected] = useState([]); const [withoutMovieFileIdRowsSelected, setWithoutMovieFileIdRowsSelected] = 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: translate('SelectDotDot'), disabled: true }, { key: 'quality', value: translate('SelectQuality') }, { key: 'releaseGroup', value: translate('SelectReleaseGroup') }, { key: 'language', value: translate('SelectLanguage') }, ]); const { allSelected, allUnselected, selectedState } = selectState; const previousIsDeleting = usePrevious(isDeleting); const dispatch = useDispatch(); const columns: Column[] = useMemo(() => { const result: Column[] = cloneDeep(COLUMNS); if (!showMovie) { const movieColumn = result.find((c) => c.name === 'movie'); if (movieColumn) { movieColumn.isVisible = false; } } return result; }, [showMovie]); const selectedIds: number[] = useMemo(() => { return getSelectedIds(selectedState); }, [selectedState]); useEffect( () => { if (allowMovieChange) { const newBulkSelectOptions = [...bulkSelectOptions]; newBulkSelectOptions.splice(1, 0, { key: 'movie', value: translate('SelectMovie'), }); setBulkSelectOptions(newBulkSelectOptions); } if (initialSortKey) { const sortProps: { sortKey: string; sortDirection?: string } = { sortKey: initialSortKey, }; if (initialSortDirection) { sortProps.sortDirection = initialSortDirection; } dispatch(setInteractiveImportSort(sortProps)); } dispatch( fetchInteractiveImportItems({ downloadId, movieId, 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, hasMovieFileId, shiftKey = false }) => { setSelectState({ type: 'toggleSelected', items, id, isSelected: value, shiftKey, }); setWithoutMovieFileIdRowsSelected( hasMovieFileId || !value ? without(withoutMovieFileIdRowsSelected, id) : [...withoutMovieFileIdRowsSelected, id] ); }, [ items, withoutMovieFileIdRowsSelected, setSelectState, setWithoutMovieFileIdRowsSelected, ] ); 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 movieFileIds = items.reduce((acc: number[], item) => { if (selectedIds.indexOf(item.id) > -1 && item.movieFileId) { acc.push(item.movieFileId); } return acc; }, []); dispatch(deleteMovieFiles({ movieFileIds })); }, [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 { movie, releaseGroup, quality, languages, movieFileId } = item; if (!movie) { setInteractiveImportErrorMessage( translate('InteractiveImportErrMovie') ); return; } if (!quality) { setInteractiveImportErrorMessage( translate('InteractiveImportErrQuality') ); return; } if (!languages) { setInteractiveImportErrorMessage( translate('InteractiveImportErrLanguage') ); return; } setInteractiveImportErrorMessage(null); if (movieFileId) { const originalItem = originalItems.find((i) => i.id === item.id); if (isSameMovieFile(item, originalItem)) { existingFiles.push({ id: movieFileId, releaseGroup, quality, languages, }); return; } } files.push({ path: item.path, folderName: item.folderName, movieId: movie.id, releaseGroup, quality, languages, downloadId, movieFileId, }); } }); let shouldClose = false; if (existingFiles.length) { dispatch( updateMovieFiles({ 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, movieId, folder, filterExistingFiles: filter, }) ); }, [downloadId, movieId, 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 onMovieSelect = useCallback( (movie: Movie) => { dispatch( updateInteractiveImportItems({ ids: selectedIds, movie, }) ); 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 errorMessage = getErrorMessage( error, translate('UnableToLoadManualImportItems') ); return ( {modalTitle} - {title || folder} {showFilterExistingFiles && (
{filterExistingFiles ? translate('UnmappedFilesOnly') : translate('AllFiles')}
{translate('AllFiles')} {translate('UnmappedFilesOnly')}
)} {isFetching ? : null} {error ?
{errorMessage}
: null} {isPopulated && !!items.length && !isFetching && !isFetching ? ( {items.map((item) => { return ( ); })}
) : null} {isPopulated && !items.length && !isFetching ? translate('NoVideoFilesFoundSelectedFolder') : null}
{showDelete ? ( {translate('Delete')} ) : null} {!downloadId && showImportMode ? ( ) : null}
{interactiveImportErrorMessage && ( {interactiveImportErrorMessage} )}
); } export default InteractiveImportModalContent;