From ec44e1c513837dda84c9f9a4803c5f28b34bbe2d Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 21 Dec 2025 20:28:59 -0800 Subject: [PATCH] Use react-query for manual import --- frontend/src/App/State/AppState.ts | 2 - .../App/State/InteractiveImportAppState.ts | 21 - .../Folder/FavoriteFolderRow.tsx | 9 +- ...eractiveImportSelectFolderModalContent.tsx | 31 +- .../Folder/RecentFolderRow.tsx | 15 +- .../InteractiveImportModalContent.tsx | 433 +++++++++--------- .../Interactive/InteractiveImportRow.tsx | 167 +++---- .../InteractiveImport/InteractiveImport.ts | 1 + .../interactiveImportFoldersStore.ts | 121 +++++ .../interactiveImportOptionsStore.ts | 111 +++++ .../InteractiveImport/useInteractiveImport.ts | 209 +++++++++ frontend/src/Store/Actions/index.js | 2 - .../Store/Actions/interactiveImportActions.js | 316 ------------- 13 files changed, 767 insertions(+), 671 deletions(-) delete mode 100644 frontend/src/App/State/InteractiveImportAppState.ts create mode 100644 frontend/src/InteractiveImport/interactiveImportFoldersStore.ts create mode 100644 frontend/src/InteractiveImport/interactiveImportOptionsStore.ts create mode 100644 frontend/src/InteractiveImport/useInteractiveImport.ts delete mode 100644 frontend/src/Store/Actions/interactiveImportActions.js diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 75af11ead..78bd2afa5 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,7 +1,6 @@ import BlocklistAppState from './BlocklistAppState'; import CaptchaAppState from './CaptchaAppState'; import ImportSeriesAppState from './ImportSeriesAppState'; -import InteractiveImportAppState from './InteractiveImportAppState'; import OAuthAppState from './OAuthAppState'; import ProviderOptionsAppState from './ProviderOptionsAppState'; import SettingsAppState from './SettingsAppState'; @@ -10,7 +9,6 @@ interface AppState { blocklist: BlocklistAppState; captcha: CaptchaAppState; importSeries: ImportSeriesAppState; - interactiveImport: InteractiveImportAppState; oAuth: OAuthAppState; providerOptions: ProviderOptionsAppState; settings: SettingsAppState; diff --git a/frontend/src/App/State/InteractiveImportAppState.ts b/frontend/src/App/State/InteractiveImportAppState.ts deleted file mode 100644 index 84fd9f4c1..000000000 --- a/frontend/src/App/State/InteractiveImportAppState.ts +++ /dev/null @@ -1,21 +0,0 @@ -import AppSectionState from 'App/State/AppSectionState'; -import ImportMode from 'InteractiveImport/ImportMode'; -import InteractiveImport from 'InteractiveImport/InteractiveImport'; - -interface FavoriteFolder { - folder: string; -} - -interface RecentFolder { - folder: string; - lastUsed: string; -} - -interface InteractiveImportAppState extends AppSectionState { - originalItems: InteractiveImport[]; - importMode: ImportMode; - favoriteFolders: FavoriteFolder[]; - recentFolders: RecentFolder[]; -} - -export default InteractiveImportAppState; diff --git a/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.tsx b/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.tsx index e39635623..7f1b3d393 100644 --- a/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.tsx +++ b/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.tsx @@ -1,10 +1,9 @@ import React, { SyntheticEvent, useCallback } from 'react'; -import { useDispatch } from 'react-redux'; import IconButton from 'Components/Link/IconButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowButton from 'Components/Table/TableRowButton'; import { icons } from 'Helpers/Props'; -import { removeFavoriteFolder } from 'Store/Actions/interactiveImportActions'; +import { removeFavoriteFolder } from 'InteractiveImport/interactiveImportFoldersStore'; import translate from 'Utilities/String/translate'; import styles from './FavoriteFolderRow.css'; @@ -14,8 +13,6 @@ interface FavoriteFolderRowProps { } function FavoriteFolderRow({ folder, onPress }: FavoriteFolderRowProps) { - const dispatch = useDispatch(); - const handlePress = useCallback(() => { onPress(folder); }, [folder, onPress]); @@ -24,9 +21,9 @@ function FavoriteFolderRow({ folder, onPress }: FavoriteFolderRowProps) { (e: SyntheticEvent) => { e.stopPropagation(); - dispatch(removeFavoriteFolder({ folder })); + removeFavoriteFolder(folder); }, - [folder, dispatch] + [folder] ); return ( diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx index c723a6e43..4649b24a5 100644 --- a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx @@ -1,7 +1,4 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; import CommandNames from 'Commands/CommandNames'; import { useExecuteCommand } from 'Commands/useCommands'; import PathInput from 'Components/Form/PathInput'; @@ -15,7 +12,11 @@ import Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import { icons, kinds, sizes } from 'Helpers/Props'; -import { addRecentFolder } from 'Store/Actions/interactiveImportActions'; +import { + addRecentFolder, + useFavoriteFolders, + useRecentFolders, +} from 'InteractiveImport/interactiveImportFoldersStore'; import translate from 'Utilities/String/translate'; import FavoriteFolderRow from './FavoriteFolderRow'; import RecentFolderRow from './RecentFolderRow'; @@ -63,20 +64,10 @@ function InteractiveImportSelectFolderModalContent( ) { const { modalTitle, onFolderSelect, onModalClose } = props; const [folder, setFolder] = useState(''); - const dispatch = useDispatch(); const executeCommand = useExecuteCommand(); - const { favoriteFolders, recentFolders } = useSelector( - createSelector( - (state: AppState) => state.interactiveImport, - (interactiveImport) => { - return { - favoriteFolders: interactiveImport.favoriteFolders, - recentFolders: interactiveImport.recentFolders, - }; - } - ) - ); + const favoriteFolders = useFavoriteFolders(); + const recentFolders = useRecentFolders(); const favoriteFolderMap = useMemo(() => { return new Map(favoriteFolders.map((f) => [f.folder, f])); @@ -97,7 +88,7 @@ function InteractiveImportSelectFolderModalContent( ); const onQuickImportPress = useCallback(() => { - dispatch(addRecentFolder({ folder })); + addRecentFolder(folder); executeCommand({ name: CommandNames.DownloadedEpisodesScan, @@ -105,12 +96,12 @@ function InteractiveImportSelectFolderModalContent( }); onModalClose(); - }, [folder, onModalClose, dispatch, executeCommand]); + }, [folder, onModalClose, executeCommand]); const onInteractiveImportPress = useCallback(() => { - dispatch(addRecentFolder({ folder })); + addRecentFolder(folder); onFolderSelect(folder); - }, [folder, onFolderSelect, dispatch]); + }, [folder, onFolderSelect]); return ( diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.tsx b/frontend/src/InteractiveImport/Folder/RecentFolderRow.tsx index 31d164e1a..d77c0ae03 100644 --- a/frontend/src/InteractiveImport/Folder/RecentFolderRow.tsx +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.tsx @@ -1,5 +1,4 @@ import React, { SyntheticEvent, useCallback } from 'react'; -import { useDispatch } from 'react-redux'; import IconButton from 'Components/Link/IconButton'; import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; @@ -9,7 +8,7 @@ import { addFavoriteFolder, removeFavoriteFolder, removeRecentFolder, -} from 'Store/Actions/interactiveImportActions'; +} from 'InteractiveImport/interactiveImportFoldersStore'; import translate from 'Utilities/String/translate'; import styles from './RecentFolderRow.css'; @@ -26,8 +25,6 @@ function RecentFolderRow({ isFavorite, onPress, }: RecentFolderRowProps) { - const dispatch = useDispatch(); - const handlePress = useCallback(() => { onPress(folder); }, [folder, onPress]); @@ -37,21 +34,21 @@ function RecentFolderRow({ e.stopPropagation(); if (isFavorite) { - dispatch(removeFavoriteFolder({ folder })); + removeFavoriteFolder(folder); } else { - dispatch(addFavoriteFolder({ folder })); + addFavoriteFolder(folder); } }, - [folder, isFavorite, dispatch] + [folder, isFavorite] ); const handleRemovePress = useCallback( (e: SyntheticEvent) => { e.stopPropagation(); - dispatch(removeRecentFolder({ folder })); + removeRecentFolder(folder); }, - [folder, dispatch] + [folder] ); return ( diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx index eefe0f08f..ba0d1e60a 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx @@ -1,10 +1,7 @@ import { cloneDeep, without } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; +import { create } from 'zustand'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; -import AppState from 'App/State/AppState'; -import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; import CommandNames from 'Commands/CommandNames'; import { useExecuteCommand } from 'Commands/useCommands'; import SelectInput, { SelectInputOption } from 'Components/Form/SelectInput'; @@ -31,6 +28,7 @@ import { } from 'EpisodeFile/useEpisodeFiles'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { align, icons, kinds, scrollDirections } from 'Helpers/Props'; +import { SortDirection } from 'Helpers/Props/sortDirections'; import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; import { SelectedEpisode } from 'InteractiveImport/Episode/SelectEpisodeModalContent'; import ImportMode from 'InteractiveImport/ImportMode'; @@ -38,25 +36,26 @@ import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexe import InteractiveImport, { InteractiveImportCommandOptions, } from 'InteractiveImport/InteractiveImport'; +import { + setInteractiveImportOption, + setInteractiveImportSort, + useInteractiveImportOptions, +} from 'InteractiveImport/interactiveImportOptionsStore'; import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; +import ReleaseType from 'InteractiveImport/ReleaseType'; import SelectReleaseTypeModal from 'InteractiveImport/ReleaseType/SelectReleaseTypeModal'; import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import useInteractiveImport, { + useReprocessInteractiveImportItems, + useUpdateInteractiveImportItem, + useUpdateInteractiveImportItems, +} from 'InteractiveImport/useInteractiveImport'; import Language from 'Language/Language'; import { QualityModel } from 'Quality/Quality'; import Series from 'Series/Series'; -import { - clearInteractiveImport, - fetchInteractiveImportItems, - reprocessInteractiveImportItems, - setInteractiveImportMode, - setInteractiveImportSort, - updateInteractiveImportItem, - updateInteractiveImportItems, -} from 'Store/Actions/interactiveImportActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SortCallback } from 'typings/callbacks'; import { CheckInputChanged } from 'typings/inputs'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; @@ -200,12 +199,7 @@ function isSameEpisodeFile( return !hasDifferentItems(originalFile.episodes, episodes); } -const importModeSelector = createSelector( - (state: AppState) => state.interactiveImport.importMode, - (importMode) => { - return importMode; - } -); +const filterExistingFilesStore = create(() => false); export interface InteractiveImportModalContentProps { downloadId?: string; @@ -246,25 +240,40 @@ function InteractiveImportModalContentInner( onModalClose, } = props; + const filterExistingFiles = filterExistingFilesStore((state) => state); + const [reprocessingItems, setReprocessingItems] = useState>( + new Set() + ); + const { isFetching, - isPopulated, + isFetched: isPopulated, error, - items, + data, originalItems, - sortKey, - sortDirection, - }: InteractiveImportAppState = useSelector( - createClientSideCollectionSelector('interactiveImport') - ); + } = useInteractiveImport({ + downloadId, + seriesId, + seasonNumber, + folder, + filterExistingFiles, + }); + + const { sortKey, sortDirection, importMode } = useInteractiveImportOptions(); + + const { updateInteractiveImportItem } = useUpdateInteractiveImportItem(); + const { updateInteractiveImportItems } = useUpdateInteractiveImportItems(); + + const { reprocessInteractiveImportItems } = + useReprocessInteractiveImportItems(); + + const items = data; const { isDeleting, deleteEpisodeFiles, deleteError } = useDeleteEpisodeFiles(); const { updateEpisodeFiles } = useUpdateEpisodeFiles(); - const importMode = useSelector(importModeSelector); - const [invalidRowsSelected, setInvalidRowsSelected] = useState([]); const [ withoutEpisodeFileIdRowsSelected, @@ -275,11 +284,9 @@ function InteractiveImportModalContentInner( ); const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] = useState(false); - const [filterExistingFiles, setFilterExistingFiles] = useState(false); const [interactiveImportErrorMessage, setInteractiveImportErrorMessage] = useState(null); const previousIsDeleting = usePrevious(isDeleting); - const dispatch = useDispatch(); const executeCommand = useExecuteCommand(); const { @@ -392,31 +399,14 @@ function InteractiveImportModalContentInner( useEffect( () => { if (initialSortKey) { - const sortProps: { sortKey: string; sortDirection?: string } = { + const sortDirection: SortDirection = + (initialSortDirection as SortDirection) || 'ascending'; + + setInteractiveImportSort({ sortKey: initialSortKey, - }; - - if (initialSortDirection) { - sortProps.sortDirection = initialSortDirection; - } - - dispatch(setInteractiveImportSort(sortProps)); + sortDirection, + }); } - - 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 [] @@ -428,7 +418,7 @@ function InteractiveImportModalContentInner( } }, [previousIsDeleting, isDeleting, deleteError, onModalClose]); - const onSelectAllChange = useCallback( + const handleSelectAllChange = useCallback( ({ value }: CheckInputChanged) => { if (value) { selectAll(); @@ -439,7 +429,7 @@ function InteractiveImportModalContentInner( [selectAll, unselectAll] ); - const onSelectedChange = useCallback( + const handleSelectedChange = useCallback( ({ id, value, hasEpisodeFileId, shiftKey = false }) => { toggleSelected({ id, @@ -460,7 +450,7 @@ function InteractiveImportModalContentInner( ] ); - const onValidRowChange = useCallback( + const handleValidRowChange = useCallback( (id: number, isValid: boolean) => { if (isValid && invalidRowsSelected.includes(id)) { setInvalidRowsSelected(without(invalidRowsSelected, id)); @@ -471,11 +461,11 @@ function InteractiveImportModalContentInner( [invalidRowsSelected, setInvalidRowsSelected] ); - const onDeleteSelectedPress = useCallback(() => { + const handleDeleteSelectedPress = useCallback(() => { setIsConfirmDeleteModalOpen(true); }, [setIsConfirmDeleteModalOpen]); - const onConfirmDelete = useCallback(() => { + const handleConfirmDelete = useCallback(() => { setIsConfirmDeleteModalOpen(false); const episodeFileIds = items.reduce((acc: number[], item) => { @@ -489,11 +479,11 @@ function InteractiveImportModalContentInner( deleteEpisodeFiles({ episodeFileIds }); }, [items, selectedIds, setIsConfirmDeleteModalOpen, deleteEpisodeFiles]); - const onConfirmDeleteModalClose = useCallback(() => { + const handleConfirmDeleteModalClose = useCallback(() => { setIsConfirmDeleteModalOpen(false); }, [setIsConfirmDeleteModalOpen]); - const onImportSelectedPress = useCallback(() => { + const handleImportSelectedPress = useCallback(() => { const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode; const existingFiles: Partial[] = []; @@ -626,41 +616,38 @@ function InteractiveImportModalContentInner( updateEpisodeFiles, ]); - const onSortPress = useCallback( - (sortKey, sortDirection) => { - dispatch(setInteractiveImportSort({ sortKey, sortDirection })); + const handleSetInteractiveImportMode = useCallback( + ({ importMode }: { importMode: ImportMode }) => { + setInteractiveImportOption('importMode', importMode); }, - [dispatch] + [] ); - const onFilterExistingFilesChange = useCallback( + const handleSortPress = useCallback( + (sortKey, sortDirection) => { + setInteractiveImportSort({ sortKey, sortDirection }); + }, + [] + ); + + const handleFilterExistingFilesChange = useCallback( (value: string | undefined) => { const filter = value !== 'all'; - - setFilterExistingFiles(filter); - - dispatch( - fetchInteractiveImportItems({ - downloadId, - seriesId, - folder, - filterExistingFiles: filter, - }) - ); + filterExistingFilesStore.setState(filter); }, - [downloadId, seriesId, folder, setFilterExistingFiles, dispatch] + [] ); - const onImportModeChange = useCallback< + const handleImportModeChange = useCallback< ({ value }: { value: ImportMode }) => void >( ({ value }) => { - dispatch(setInteractiveImportMode({ importMode: value })); + handleSetInteractiveImportMode({ importMode: value }); }, - [dispatch] + [handleSetInteractiveImportMode] ); - const onSelectModalSelect = useCallback< + const handleSelectModalSelect = useCallback< ({ value }: { value: SelectType }) => void >( ({ value }) => { @@ -669,143 +656,154 @@ function InteractiveImportModalContentInner( [setSelectModalOpen] ); - const onSelectModalClose = useCallback(() => { + const handleSelectModalClose = useCallback(() => { setSelectModalOpen(null); }, [setSelectModalOpen]); - const onSeriesSelect = useCallback( - (series: Series) => { - dispatch( - updateInteractiveImportItems({ - ids: selectedIds, - series, - seasonNumber: undefined, - episodes: [], - }) - ); + const handleReprocessItems = useCallback( + (ids: number[]) => { + setReprocessingItems((prev) => { + const newSet = new Set(prev); - dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + ids.forEach((id) => newSet.add(id)); - 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( - (selectedEpisodes: SelectedEpisode[]) => { - selectedEpisodes.forEach((selectedEpisode) => { - const { id, episodes } = selectedEpisode; - - dispatch( - updateInteractiveImportItem({ - id, - episodes, - }) - ); + return newSet; }); - dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + reprocessInteractiveImportItems(ids); + }, + [reprocessInteractiveImportItems] + ); + + const handleSeriesSelect = useCallback( + (series: Series) => { + const updates = { + series, + seasonNumber: undefined, + episodes: [], + }; + + updateInteractiveImportItems(selectedIds, updates); + + handleReprocessItems(selectedIds); + setSelectModalOpen(null); + }, + [ + selectedIds, + updateInteractiveImportItems, + setSelectModalOpen, + handleReprocessItems, + ] + ); + + const handleSeasonSelect = useCallback( + (seasonNumber: number) => { + const updates = { + seasonNumber, + episodes: [], + }; + + updateInteractiveImportItems(selectedIds, updates); + handleReprocessItems(selectedIds); setSelectModalOpen(null); }, - [selectedIds, setSelectModalOpen, dispatch] + [ + selectedIds, + setSelectModalOpen, + updateInteractiveImportItems, + handleReprocessItems, + ] ); - const onReleaseGroupSelect = useCallback( + const handleEpisodesSelect = useCallback( + (selectedEpisodes: SelectedEpisode[]) => { + selectedEpisodes.forEach(({ id, episodes }) => { + updateInteractiveImportItem(id, { episodes }); + }); + + const selectedIds = selectedEpisodes.map(({ id }) => id); + handleReprocessItems(selectedIds); + setSelectModalOpen(null); + }, + [updateInteractiveImportItem, setSelectModalOpen, handleReprocessItems] + ); + + const handleReleaseGroupSelect = useCallback( (releaseGroup: string) => { - dispatch( - updateInteractiveImportItems({ - ids: selectedIds, - releaseGroup, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + updateInteractiveImportItems(selectedIds, { releaseGroup }); + handleReprocessItems(selectedIds); setSelectModalOpen(null); }, - [selectedIds, dispatch] + [ + selectedIds, + updateInteractiveImportItems, + setSelectModalOpen, + handleReprocessItems, + ] ); - const onLanguagesSelect = useCallback( + const handleLanguagesSelect = useCallback( (newLanguages: Language[]) => { - dispatch( - updateInteractiveImportItems({ - ids: selectedIds, - languages: newLanguages, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + updateInteractiveImportItems(selectedIds, { languages: newLanguages }); + handleReprocessItems(selectedIds); setSelectModalOpen(null); }, - [selectedIds, dispatch] + [ + selectedIds, + updateInteractiveImportItems, + setSelectModalOpen, + handleReprocessItems, + ] ); - const onQualitySelect = useCallback( + const handleQualitySelect = useCallback( (quality: QualityModel) => { - dispatch( - updateInteractiveImportItems({ - ids: selectedIds, - quality, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + updateInteractiveImportItems(selectedIds, { quality }); + handleReprocessItems(selectedIds); setSelectModalOpen(null); }, - [selectedIds, dispatch] + [ + selectedIds, + updateInteractiveImportItems, + setSelectModalOpen, + handleReprocessItems, + ] ); - const onIndexerFlagsSelect = useCallback( + const handleIndexerFlagsSelect = useCallback( (indexerFlags: number) => { - dispatch( - updateInteractiveImportItems({ - ids: selectedIds, - indexerFlags, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + updateInteractiveImportItems(selectedIds, { indexerFlags }); + handleReprocessItems(selectedIds); setSelectModalOpen(null); }, - [selectedIds, dispatch] + [ + selectedIds, + updateInteractiveImportItems, + setSelectModalOpen, + handleReprocessItems, + ] ); - const onReleaseTypeSelect = useCallback( + const handleReleaseTypeSelect = useCallback( (releaseType: string) => { - dispatch( - updateInteractiveImportItems({ - ids: selectedIds, - releaseType, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: selectedIds })); + updateInteractiveImportItems(selectedIds, { + releaseType: releaseType as ReleaseType, + }); + handleReprocessItems(selectedIds); setSelectModalOpen(null); }, - [selectedIds, dispatch] + [ + selectedIds, + updateInteractiveImportItems, + setSelectModalOpen, + handleReprocessItems, + ] ); const orderedSelectedIds = items.reduce((acc: number[], file) => { @@ -832,7 +830,7 @@ function InteractiveImportModalContentInner( - {showFilterExistingFiles && ( + {showFilterExistingFiles ? (
@@ -849,7 +847,7 @@ function InteractiveImportModalContentInner( {translate('AllFiles')} @@ -857,14 +855,14 @@ function InteractiveImportModalContentInner( {translate('UnmappedFilesOnly')}
- )} + ) : null} {isFetching ? : null} @@ -879,8 +877,8 @@ function InteractiveImportModalContentInner( allUnselected={allUnselected} sortKey={sortKey} sortDirection={sortDirection} - onSortPress={onSortPress} - onSelectAllChange={onSelectAllChange} + onSortPress={handleSortPress} + onSelectAllChange={handleSelectAllChange} > {items.map((item) => { @@ -891,8 +889,10 @@ function InteractiveImportModalContentInner( allowSeriesChange={allowSeriesChange} columns={columns} modalTitle={modalTitle} - onSelectedChange={onSelectedChange} - onValidRowChange={onValidRowChange} + isReprocessing={reprocessingItems.has(item.id)} + onReprocessItems={handleReprocessItems} + onSelectedChange={handleSelectedChange} + onValidRowChange={handleValidRowChange} /> ); })} @@ -915,7 +915,7 @@ function InteractiveImportModalContentInner( isDisabled={ !selectedIds.length || !!withoutEpisodeFileIdRowsSelected.length } - onPress={onDeleteSelectedPress} + onPress={handleDeleteSelectedPress} > {translate('Delete')} @@ -927,7 +927,7 @@ function InteractiveImportModalContentInner( name="importMode" value={importMode} values={importModeOptions} - onChange={onImportModeChange} + onChange={handleImportModeChange} /> ) : null} @@ -937,7 +937,7 @@ function InteractiveImportModalContentInner( value="select" values={bulkSelectOptions} isDisabled={!selectedIds.length} - onChange={onSelectModalSelect} + onChange={handleSelectModalSelect} /> @@ -953,7 +953,7 @@ function InteractiveImportModalContentInner( @@ -963,16 +963,16 @@ function InteractiveImportModalContentInner(
); @@ -1044,12 +1044,19 @@ function InteractiveImportModalContentInner( function InteractiveImportModalContent( props: InteractiveImportModalContentProps ) { - const { items }: InteractiveImportAppState = useSelector( - createClientSideCollectionSelector('interactiveImport') - ); + const filterExistingFiles = filterExistingFilesStore((state) => state); + + const { downloadId, seriesId, seasonNumber, folder } = props; + const { data } = useInteractiveImport({ + downloadId, + seriesId, + seasonNumber, + folder, + filterExistingFiles, + }); return ( - items={items}> + items={data}> ); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx index b50f2ab00..8e723c889 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; import { useSelect } from 'App/Select/SelectContext'; import Icon from 'Components/Icon'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -27,13 +26,10 @@ import ReleaseType from 'InteractiveImport/ReleaseType'; import SelectReleaseTypeModal from 'InteractiveImport/ReleaseType/SelectReleaseTypeModal'; import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import { useUpdateInteractiveImportItem } from 'InteractiveImport/useInteractiveImport'; import Language from 'Language/Language'; import { QualityModel } from 'Quality/Quality'; import Series from 'Series/Series'; -import { - reprocessInteractiveImportItems, - updateInteractiveImportItem, -} from 'Store/Actions/interactiveImportActions'; import CustomFormat from 'typings/CustomFormat'; import { SelectStateInputProps } from 'typings/props'; import Rejection from 'typings/Rejection'; @@ -77,6 +73,7 @@ interface InteractiveImportRowProps { episodeFileId?: number; isReprocessing?: boolean; modalTitle: string; + onReprocessItems: (ids: number[]) => void; onSelectedChange(result: SelectedChangeProps): void; onValidRowChange(id: number, isValid: boolean): void; } @@ -102,13 +99,14 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { modalTitle, episodeFileId, columns, + onReprocessItems, onSelectedChange, onValidRowChange, } = props; - const dispatch = useDispatch(); const { useIsSelected } = useSelect(); const isSelected = useIsSelected(id); + const { updateInteractiveImportItem } = useUpdateInteractiveImportItem(); const isSeriesColumnVisible = useMemo( () => columns.find((c) => c.name === 'series')?.isVisible ?? false, @@ -202,21 +200,23 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const onSeriesSelect = useCallback( (series: Series) => { - dispatch( - updateInteractiveImportItem({ - id, - series, - seasonNumber: undefined, - episodes: [], - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: [id] })); + updateInteractiveImportItem(id, { + series, + seasonNumber: undefined, + episodes: [], + }); + onReprocessItems([id]); setSelectModalOpen(null); selectRowAfterChange(); }, - [id, dispatch, setSelectModalOpen, selectRowAfterChange] + [ + id, + updateInteractiveImportItem, + onReprocessItems, + setSelectModalOpen, + selectRowAfterChange, + ] ); const onSelectSeasonPress = useCallback(() => { @@ -225,20 +225,22 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const onSeasonSelect = useCallback( (seasonNumber: number) => { - dispatch( - updateInteractiveImportItem({ - id, - seasonNumber, - episodes: [], - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: [id] })); + updateInteractiveImportItem(id, { + seasonNumber, + episodes: [], + }); + onReprocessItems([id]); setSelectModalOpen(null); selectRowAfterChange(); }, - [id, dispatch, setSelectModalOpen, selectRowAfterChange] + [ + id, + updateInteractiveImportItem, + onReprocessItems, + setSelectModalOpen, + selectRowAfterChange, + ] ); const onSelectEpisodePress = useCallback(() => { @@ -247,19 +249,20 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const onEpisodesSelect = useCallback( (selectedEpisodes: SelectedEpisode[]) => { - dispatch( - updateInteractiveImportItem({ - id, - episodes: selectedEpisodes[0].episodes, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: [id] })); + const episodes = selectedEpisodes[0].episodes; + updateInteractiveImportItem(id, { episodes }); + onReprocessItems([id]); setSelectModalOpen(null); selectRowAfterChange(); }, - [id, dispatch, setSelectModalOpen, selectRowAfterChange] + [ + id, + updateInteractiveImportItem, + onReprocessItems, + setSelectModalOpen, + selectRowAfterChange, + ] ); const onSelectReleaseGroupPress = useCallback(() => { @@ -268,19 +271,19 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const onReleaseGroupSelect = useCallback( (releaseGroup: string) => { - dispatch( - updateInteractiveImportItem({ - id, - releaseGroup, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: [id] })); + updateInteractiveImportItem(id, { releaseGroup }); + onReprocessItems([id]); setSelectModalOpen(null); selectRowAfterChange(); }, - [id, dispatch, setSelectModalOpen, selectRowAfterChange] + [ + id, + updateInteractiveImportItem, + onReprocessItems, + setSelectModalOpen, + selectRowAfterChange, + ] ); const onSelectQualityPress = useCallback(() => { @@ -289,19 +292,19 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const onQualitySelect = useCallback( (quality: QualityModel) => { - dispatch( - updateInteractiveImportItem({ - id, - quality, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: [id] })); + updateInteractiveImportItem(id, { quality }); + onReprocessItems([id]); setSelectModalOpen(null); selectRowAfterChange(); }, - [id, dispatch, setSelectModalOpen, selectRowAfterChange] + [ + id, + updateInteractiveImportItem, + onReprocessItems, + setSelectModalOpen, + selectRowAfterChange, + ] ); const onSelectLanguagePress = useCallback(() => { @@ -310,19 +313,19 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const onLanguagesSelect = useCallback( (languages: Language[]) => { - dispatch( - updateInteractiveImportItem({ - id, - languages, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: [id] })); + updateInteractiveImportItem(id, { languages }); + onReprocessItems([id]); setSelectModalOpen(null); selectRowAfterChange(); }, - [id, dispatch, setSelectModalOpen, selectRowAfterChange] + [ + id, + updateInteractiveImportItem, + onReprocessItems, + setSelectModalOpen, + selectRowAfterChange, + ] ); const onSelectReleaseTypePress = useCallback(() => { @@ -331,19 +334,19 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const onReleaseTypeSelect = useCallback( (releaseType: ReleaseType) => { - dispatch( - updateInteractiveImportItem({ - id, - releaseType, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: [id] })); + updateInteractiveImportItem(id, { releaseType }); + onReprocessItems([id]); setSelectModalOpen(null); selectRowAfterChange(); }, - [id, dispatch, setSelectModalOpen, selectRowAfterChange] + [ + id, + updateInteractiveImportItem, + onReprocessItems, + setSelectModalOpen, + selectRowAfterChange, + ] ); const onSelectIndexerFlagsPress = useCallback(() => { @@ -352,19 +355,19 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { const onIndexerFlagsSelect = useCallback( (indexerFlags: number) => { - dispatch( - updateInteractiveImportItem({ - id, - indexerFlags, - }) - ); - - dispatch(reprocessInteractiveImportItems({ ids: [id] })); + updateInteractiveImportItem(id, { indexerFlags }); + onReprocessItems([id]); setSelectModalOpen(null); selectRowAfterChange(); }, - [id, dispatch, setSelectModalOpen, selectRowAfterChange] + [ + id, + updateInteractiveImportItem, + onReprocessItems, + setSelectModalOpen, + selectRowAfterChange, + ] ); const seriesTitle = series ? series.title : ''; @@ -547,7 +550,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) { body={
    {rejections.map((rejection, index) => { - return
  • {rejection.reason}
  • ; + return
  • {rejection.message}
  • ; })}
} diff --git a/frontend/src/InteractiveImport/InteractiveImport.ts b/frontend/src/InteractiveImport/InteractiveImport.ts index ba3b9d3df..2d550fed3 100644 --- a/frontend/src/InteractiveImport/InteractiveImport.ts +++ b/frontend/src/InteractiveImport/InteractiveImport.ts @@ -39,6 +39,7 @@ interface InteractiveImport extends ModelBase { releaseType: ReleaseType; rejections: Rejection[]; episodeFileId?: number; + downloadId?: string; } export default InteractiveImport; diff --git a/frontend/src/InteractiveImport/interactiveImportFoldersStore.ts b/frontend/src/InteractiveImport/interactiveImportFoldersStore.ts new file mode 100644 index 000000000..5006243dd --- /dev/null +++ b/frontend/src/InteractiveImport/interactiveImportFoldersStore.ts @@ -0,0 +1,121 @@ +import moment from 'moment'; +import { createPersist } from 'Helpers/createPersist'; +import sortByProp from 'Utilities/Array/sortByProp'; + +const MAXIMUM_RECENT_FOLDERS = 10; + +interface RecentFolder { + folder: string; + lastUsed: string; +} + +interface FavoriteFolder { + folder: string; +} + +interface InteractiveImportFoldersState { + recentFolders: RecentFolder[]; + favoriteFolders: FavoriteFolder[]; +} + +const store = createPersist( + 'interactive_import_folders', + () => ({ + recentFolders: [], + favoriteFolders: [], + }) +); + +export const useInteractiveImportFolders = () => { + return store((state) => state); +}; + +export const useRecentFolders = () => { + return store((state) => state.recentFolders); +}; + +export const useFavoriteFolders = () => { + return store((state) => state.favoriteFolders); +}; + +export const addRecentFolder = (folder: string) => { + store.setState((state) => { + const recentFolder: RecentFolder = { + folder, + lastUsed: moment().toISOString(), + }; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + if (index > -1) { + recentFolders.splice(index, 1); + } + + recentFolders.push(recentFolder); + + const sliceIndex = Math.max( + recentFolders.length - MAXIMUM_RECENT_FOLDERS, + 0 + ); + + return { + ...state, + recentFolders: recentFolders.slice(sliceIndex), + }; + }); +}; + +export const removeRecentFolder = (folder: string) => { + store.setState((state) => { + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + if (index > -1) { + recentFolders.splice(index, 1); + } + + return { + ...state, + recentFolders, + }; + }); +}; + +export const addFavoriteFolder = (folder: string) => { + store.setState((state) => { + const favoriteFolder: FavoriteFolder = { folder }; + const favoriteFolders = [...state.favoriteFolders, favoriteFolder].sort( + sortByProp('folder') + ); + + return { + ...state, + favoriteFolders, + }; + }); +}; + +export const removeFavoriteFolder = (folder: string) => { + store.setState((state) => { + const favoriteFolders = state.favoriteFolders.filter( + (item) => item.folder !== folder + ); + + return { + ...state, + favoriteFolders, + }; + }); +}; + +export const getInteractiveImportFolders = () => { + return store.getState(); +}; + +export const getRecentFolders = () => { + return store.getState().recentFolders; +}; + +export const getFavoriteFolders = () => { + return store.getState().favoriteFolders; +}; diff --git a/frontend/src/InteractiveImport/interactiveImportOptionsStore.ts b/frontend/src/InteractiveImport/interactiveImportOptionsStore.ts new file mode 100644 index 000000000..351e928cb --- /dev/null +++ b/frontend/src/InteractiveImport/interactiveImportOptionsStore.ts @@ -0,0 +1,111 @@ +import { + createOptionsStore, + PageableOptions, +} from 'Helpers/Hooks/useOptionsStore'; +import ImportMode from './ImportMode'; + +export interface InteractiveImportOptions + extends Omit { + importMode: ImportMode; +} + +export 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: 'Episodes', + 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: 'releaseType', + label: 'Release Type', + isSortable: true, + isVisible: true, + }, + { + name: 'customFormats', + label: 'Custom Formats', + isSortable: true, + isVisible: true, + }, + { + name: 'indexerFlags', + label: 'Indexer Flags', + isSortable: true, + isVisible: true, + }, + { + name: 'rejections', + label: 'Rejections', + isSortable: true, + isVisible: true, + }, +]; + +const { + useOptions, + useOption, + getOptions, + getOption, + setOptions, + setOption, + setSort, +} = createOptionsStore( + 'interactive_import_options', + () => { + return { + selectedFilterKey: 'all', + sortKey: 'relativePath', + sortDirection: 'ascending', + importMode: 'chooseImportMode', + columns: COLUMNS, + }; + } +); + +export const useInteractiveImportOptions = useOptions; +export const getInteractiveImportOptions = getOptions; +export const setInteractiveImportOptions = setOptions; +export const useInteractiveImportOption = useOption; +export const getInteractiveImportOption = getOption; +export const setInteractiveImportOption = setOption; +export const setInteractiveImportSort = setSort; diff --git a/frontend/src/InteractiveImport/useInteractiveImport.ts b/frontend/src/InteractiveImport/useInteractiveImport.ts new file mode 100644 index 000000000..6697e4da2 --- /dev/null +++ b/frontend/src/InteractiveImport/useInteractiveImport.ts @@ -0,0 +1,209 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import ModelBase from 'App/ModelBase'; +import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import clientSideFilterAndSort from 'Utilities/Filter/clientSideFilterAndSort'; +import InteractiveImport from './InteractiveImport'; +import { useInteractiveImportOptions } from './interactiveImportOptionsStore'; +import ReleaseType from './ReleaseType'; + +const DEFAULT_ITEMS: InteractiveImport[] = []; + +interface InteractiveImportParams { + downloadId?: string; + seriesId?: number; + seasonNumber?: number; + folder?: string; + filterExistingFiles?: boolean; +} + +const useInteractiveImport = (params: InteractiveImportParams) => { + const { sortKey, sortDirection } = useInteractiveImportOptions(); + + const { data, isFetching, isFetched, error, refetch } = useApiQuery< + InteractiveImport[] + >({ + path: '/manualimport', + queryParams: { ...params }, + queryOptions: { + // Set to 0 so we don't persist the data after the modal is closed and the query becomes inactive + gcTime: 0, + // Disable refetch on window focus to prevent refetching when the user switch tabs + refetchOnWindowFocus: false, + }, + }); + + const items = data ?? DEFAULT_ITEMS; + const originalItems = [...items]; + + const { data: sortedItems } = useMemo(() => { + const sortPredicates = { + series: (item: InteractiveImport) => item.series?.title || '', + quality: (item: InteractiveImport) => item.quality?.quality?.name || '', + languages: (item: InteractiveImport) => + item.languages?.map((l) => l.name).join(', ') || '', + }; + + return clientSideFilterAndSort(items, { + sortKey, + sortDirection, + sortPredicates, + }); + }, [items, sortKey, sortDirection]); + + return { + data: sortedItems, + originalItems, + isFetching, + isFetched, + error, + refetch, + }; +}; + +export default useInteractiveImport; + +export const useUpdateInteractiveImportItem = () => { + const queryClient = useQueryClient(); + + const updateInteractiveImportItem = useCallback( + (id: number, updates: Partial) => { + queryClient.setQueriesData( + { queryKey: ['/manualimport'] }, + (oldData: InteractiveImport[] | undefined) => { + if (!oldData) { + return oldData; + } + + return oldData.map((item) => { + return item.id === id ? { ...item, ...updates } : item; + }); + } + ); + }, + [queryClient] + ); + + return { updateInteractiveImportItem }; +}; + +export const useUpdateInteractiveImportItems = () => { + const queryClient = useQueryClient(); + + const updateInteractiveImportItems = useCallback( + (ids: number[], updates: Partial) => { + queryClient.setQueriesData( + { queryKey: ['/manualimport'] }, + (oldData: InteractiveImport[] | undefined) => { + if (!oldData) { + return oldData; + } + + return oldData.map((item) => { + return ids.includes(item.id) ? { ...item, ...updates } : item; + }); + } + ); + }, + [queryClient] + ); + + return { updateInteractiveImportItems }; +}; + +interface ReprocessInteractiveImportItem extends ModelBase { + path: string; + seriesId: number | undefined; + seasonNumber: number | undefined; + episodeIds: number[] | undefined; + quality: QualityModel | undefined; + languages: Language[]; + releaseGroup: string | undefined; + downloadId: string | undefined; + indexerFlags: number; + releaseType: ReleaseType; +} + +export const useReprocessInteractiveImportItems = () => { + const queryClient = useQueryClient(); + + const { mutate, isPending, error } = useApiMutation< + InteractiveImport[], + ReprocessInteractiveImportItem[] + >({ + path: '/manualimport', + method: 'POST', + mutationOptions: { + onSuccess: (updatedItems) => { + queryClient.setQueriesData( + { queryKey: ['/manualimport'] }, + (oldData: InteractiveImport[] | undefined) => { + if (!oldData) { + return oldData; + } + + return oldData.map((oldItem: InteractiveImport) => { + const reprocessedItem = updatedItems.find( + (updatedItem) => updatedItem.id === oldItem.id + ); + + return reprocessedItem ? reprocessedItem : oldItem; + }); + } + ); + }, + }, + }); + + const reprocessInteractiveImportItems = useCallback( + (ids: number[]) => { + const [, currentData] = queryClient.getQueriesData({ + queryKey: ['/manualimport'], + })[0]; + + if (!currentData) { + console.info('\x1b[36m[MarkTest] no data\x1b[0m'); + return; + } + + const requestPayload = ids.reduce( + (acc, id) => { + const item = currentData.find((i) => i.id === id); + + if (!item) { + return acc; + } + + acc.push({ + id, + path: item.path, + seriesId: item.series ? item.series.id : undefined, + seasonNumber: item.seasonNumber, + episodeIds: (item.episodes || []).map((e) => e.id), + quality: item.quality, + languages: item.languages, + releaseGroup: item.releaseGroup, + indexerFlags: item.indexerFlags, + releaseType: item.releaseType, + downloadId: item.downloadId, + }); + + return acc; + }, + [] + ); + + mutate(requestPayload); + }, + [queryClient, mutate] + ); + + return { + reprocessInteractiveImportItems, + isReprocessing: isPending, + error, + }; +}; diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index cb3561ca4..b79564adc 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -1,6 +1,5 @@ import * as captcha from './captchaActions'; import * as importSeries from './importSeriesActions'; -import * as interactiveImportActions from './interactiveImportActions'; import * as oAuth from './oAuthActions'; import * as providerOptions from './providerOptionActions'; import * as settings from './settingsActions'; @@ -8,7 +7,6 @@ import * as settings from './settingsActions'; export default [ captcha, importSeries, - interactiveImportActions, oAuth, providerOptions, settings diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js deleted file mode 100644 index ca0acc56a..000000000 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ /dev/null @@ -1,316 +0,0 @@ -import moment from 'moment'; -import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; -import { sortDirections } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; -import sortByProp from 'Utilities/Array/sortByProp'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import naturalExpansion from 'Utilities/String/naturalExpansion'; -import { set, update, updateItem } from './baseActions'; -import createHandleActions from './Creators/createHandleActions'; -import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; - -// -// Variables - -export const section = 'interactiveImport'; - -let abortCurrentRequest = null; -let currentIds = []; - -const MAXIMUM_RECENT_FOLDERS = 10; - -// -// State - -export const defaultState = { - isFetching: false, - isPopulated: false, - error: null, - items: [], - originalItems: [], - sortKey: 'relativePath', - sortDirection: sortDirections.ASCENDING, - favoriteFolders: [], - recentFolders: [], - importMode: 'chooseImportMode', - sortPredicates: { - relativePath: function(item, direction) { - const relativePath = item.relativePath; - - return naturalExpansion(relativePath.toLowerCase()); - }, - - series: function(item, direction) { - const series = item.series; - - return series ? series.sortTitle : ''; - }, - - quality: function(item, direction) { - return item.qualityWeight || 0; - }, - - customFormats: function(item, direction) { - return item.customFormatScore; - } - } -}; - -export const persistState = [ - 'interactiveImport.sortKey', - 'interactiveImport.sortDirection', - 'interactiveImport.favoriteFolders', - 'interactiveImport.recentFolders', - 'interactiveImport.importMode' -]; - -// -// Actions Types - -export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems'; -export const REPROCESS_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/reprocessInteractiveImportItems'; -export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort'; -export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem'; -export const UPDATE_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/updateInteractiveImportItems'; -export const CLEAR_INTERACTIVE_IMPORT = 'interactiveImport/clearInteractiveImport'; -export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder'; -export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder'; -export const ADD_FAVORITE_FOLDER = 'interactiveImport/addFavoriteFolder'; -export const REMOVE_FAVORITE_FOLDER = 'interactiveImport/removeFavoriteFolder'; -export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode'; - -// -// Action Creators - -export const fetchInteractiveImportItems = createThunk(FETCH_INTERACTIVE_IMPORT_ITEMS); -export const reprocessInteractiveImportItems = createThunk(REPROCESS_INTERACTIVE_IMPORT_ITEMS); -export const setInteractiveImportSort = createAction(SET_INTERACTIVE_IMPORT_SORT); -export const updateInteractiveImportItem = createAction(UPDATE_INTERACTIVE_IMPORT_ITEM); -export const updateInteractiveImportItems = createAction(UPDATE_INTERACTIVE_IMPORT_ITEMS); -export const clearInteractiveImport = createAction(CLEAR_INTERACTIVE_IMPORT); -export const addRecentFolder = createAction(ADD_RECENT_FOLDER); -export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER); -export const addFavoriteFolder = createAction(ADD_FAVORITE_FOLDER); -export const removeFavoriteFolder = createAction(REMOVE_FAVORITE_FOLDER); -export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE); - -// -// Action Handlers -export const actionHandlers = handleThunks({ - [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) { - if (!payload.downloadId && !payload.folder) { - dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } })); - return; - } - - dispatch(set({ section, isFetching: true })); - - const promise = createAjaxRequest({ - url: '/manualimport', - data: payload - }).request; - - promise.done((data) => { - dispatch(batchActions([ - update({ section, data }), - - set({ - section, - isFetching: false, - isPopulated: true, - error: null, - originalItems: data - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isFetching: false, - isPopulated: false, - error: xhr - })); - }); - }, - - [REPROCESS_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) { - if (abortCurrentRequest) { - abortCurrentRequest(); - } - - dispatch(batchActions([ - ...currentIds.map((id) => updateItem({ - section, - id, - isReprocessing: false, - updateOnly: true - })), - ...payload.ids.map((id) => updateItem({ - section, - id, - isReprocessing: true, - updateOnly: true - })) - ])); - - const items = getState()[section].items; - - const requestPayload = payload.ids.map((id) => { - const item = items.find((i) => i.id === id); - - return { - id, - path: item.path, - seriesId: item.series ? item.series.id : undefined, - seasonNumber: item.seasonNumber, - episodeIds: (item.episodes || []).map((e) => e.id), - quality: item.quality, - languages: item.languages, - releaseGroup: item.releaseGroup, - indexerFlags: item.indexerFlags, - releaseType: item.releaseType, - downloadId: item.downloadId - }; - }); - - const { request, abortRequest } = createAjaxRequest({ - method: 'POST', - url: '/manualimport', - contentType: 'application/json', - data: JSON.stringify(requestPayload) - }); - - abortCurrentRequest = abortRequest; - currentIds = payload.ids; - - request.done((data) => { - dispatch(batchActions( - data.map((item) => updateItem({ - section, - ...item, - isReprocessing: false, - updateOnly: true - })) - )); - }); - - request.fail((xhr) => { - if (xhr.aborted) { - return; - } - - dispatch(batchActions( - payload.ids.map((id) => updateItem({ - section, - id, - isReprocessing: false, - updateOnly: true - })) - )); - }); - } -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => { - const id = payload.id; - const newState = Object.assign({}, state); - const items = newState.items; - const index = items.findIndex((item) => item.id === id); - const item = Object.assign({}, items[index], payload); - - newState.items = [...items]; - newState.items.splice(index, 1, item); - - return newState; - }, - - [UPDATE_INTERACTIVE_IMPORT_ITEMS]: (state, { payload }) => { - const { ids, ...otherPayload } = payload; - const newState = Object.assign({}, state); - const items = [...newState.items]; - - ids.forEach((id) => { - const index = items.findIndex((item) => item.id === id); - const item = Object.assign({}, items[index], otherPayload); - - items.splice(index, 1, item); - }); - - newState.items = items; - - return newState; - }, - - [ADD_RECENT_FOLDER]: function(state, { payload }) { - const folder = payload.folder; - const recentFolder = { folder, lastUsed: moment().toISOString() }; - const recentFolders = [...state.recentFolders]; - const index = recentFolders.findIndex((r) => r.folder === folder); - - if (index > -1) { - recentFolders.splice(index, 1); - } - - recentFolders.push(recentFolder); - - const sliceIndex = Math.max(recentFolders.length - MAXIMUM_RECENT_FOLDERS, 0); - - return Object.assign({}, state, { recentFolders: recentFolders.slice(sliceIndex) }); - }, - - [REMOVE_RECENT_FOLDER]: function(state, { payload }) { - const folder = payload.folder; - const recentFolders = [...state.recentFolders]; - const index = recentFolders.findIndex((r) => r.folder === folder); - - recentFolders.splice(index, 1); - - return Object.assign({}, state, { recentFolders }); - }, - - [ADD_FAVORITE_FOLDER]: function(state, { payload }) { - const folder = payload.folder; - const favoriteFolder = { folder }; - const favoriteFolders = [...state.favoriteFolders, favoriteFolder].sort(sortByProp('folder')); - - return Object.assign({}, state, { favoriteFolders }); - }, - - [REMOVE_FAVORITE_FOLDER]: function(state, { payload }) { - const folder = payload.folder; - const favoriteFolders = state.favoriteFolders.reduce((acc, item) => { - if (item.folder !== folder) { - acc.push(item); - } - - return acc; - }, []); - - return Object.assign({}, state, { favoriteFolders }); - }, - - [CLEAR_INTERACTIVE_IMPORT]: function(state) { - const newState = { - ...defaultState, - favoriteFolders: state.favoriteFolders, - recentFolders: state.recentFolders, - importMode: state.importMode - }; - - return newState; - }, - - [SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(section), - - [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) { - return Object.assign({}, state, { importMode: payload.importMode }); - } - -}, defaultState, section);