diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx index 13667ac3a..64b57dc4e 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.tsx @@ -38,11 +38,7 @@ function AddNewSeries() { setIsFetching(false); }, []); - const { - isFetching: isFetchingApi, - error, - data = [], - } = useLookupSeries(query); + const { isFetching: isFetchingApi, error, data } = useLookupSeries(query); useEffect(() => { setIsFetching(isFetchingApi); diff --git a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts index d568f7bc4..086c031d9 100644 --- a/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts +++ b/frontend/src/AddSeries/AddNewSeries/useAddSeries.ts @@ -12,18 +12,25 @@ interface AddSeriesPayload 'monitor' | 'searchForMissingEpisodes' | 'searchForCutoffUnmetEpisodes' > {} -export const useLookupSeries = (query: string) => { - return useApiQuery({ +const DEFAULT_SERIES: AddSeries[] = []; + +export const useLookupSeries = (query: string, isEnabled = true) => { + const result = useApiQuery({ path: '/series/lookup', queryParams: { term: query, }, queryOptions: { - enabled: !!query, + enabled: isEnabled && !!query, // Disable refetch on window focus to prevent refetching when the user switch tabs refetchOnWindowFocus: false, }, }); + + return { + ...result, + data: result.data ?? DEFAULT_SERIES, + }; }; export const useAddSeries = () => { diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx index aabfef521..922f066f8 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useParams } from 'react-router'; import { setAddSeriesOption, @@ -13,13 +13,12 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { kinds } from 'Helpers/Props'; import useRootFolders, { useRootFolder } from 'RootFolder/useRootFolders'; -import { clearImportSeries } from 'Store/Actions/importSeriesActions'; import translate from 'Utilities/String/translate'; import ImportSeriesFooter from './ImportSeriesFooter'; +import { clearImportSeries } from './importSeriesStore'; import ImportSeriesTable from './ImportSeriesTable'; function ImportSeries() { - const dispatch = useDispatch(); const { rootFolderId: rootFolderIdString } = useParams<{ rootFolderId: string; }>(); @@ -68,9 +67,9 @@ function ImportSeries() { useEffect(() => { return () => { - dispatch(clearImportSeries()); + clearImportSeries(); }; - }, [rootFolderId, dispatch]); + }, [rootFolderId]); useEffect(() => { if ( @@ -79,13 +78,15 @@ function ImportSeries() { ) { setAddSeriesOption('qualityProfileId', qualityProfiles[0].id); } - }, [defaultQualityProfileId, qualityProfiles, dispatch]); + }, [defaultQualityProfileId, qualityProfiles]); return ( - {rootFoldersFetching ? : null} + {rootFoldersFetching && !rootFoldersFetched ? ( + + ) : null} {!rootFoldersFetching && !!rootFoldersError ? ( @@ -103,20 +104,14 @@ function ImportSeries() { ) : null} {!rootFoldersError && - !rootFoldersFetching && rootFoldersFetched && !!unmappedFolders.length && scrollerRef.current ? ( - + ) : null} - {!rootFoldersError && - !rootFoldersFetching && - !!unmappedFolders.length ? ( + {!rootFoldersError && rootFoldersFetched && !!unmappedFolders.length ? ( ) : null} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx index 174b45d8c..38da60e94 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.tsx @@ -1,12 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { AddSeriesOptions, setAddSeriesOption, useAddSeriesOptions, } from 'AddSeries/addSeriesOptionsStore'; import { useSelect } from 'App/Select/SelectContext'; -import AppState from 'App/State/AppState'; import CheckInput from 'Components/Form/CheckInput'; import FormInputGroup from 'Components/Form/FormInputGroup'; import Icon from 'Components/Icon'; @@ -17,20 +15,22 @@ import PageContentFooter from 'Components/Page/PageContentFooter'; import Popover from 'Components/Tooltip/Popover'; import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; import { SeriesMonitor, SeriesType } from 'Series/Series'; -import { - cancelLookupSeries, - importSeries, - lookupUnsearchedSeries, - setImportSeriesValue, -} from 'Store/Actions/importSeriesActions'; import { InputChanged } from 'typings/inputs'; import translate from 'Utilities/String/translate'; +import { + ImportSeriesItem, + startProcessing, + stopProcessing, + updateImportSeriesItem, + useImportSeriesItems, + useLookupQueueHasItems, +} from './importSeriesStore'; +import { useImportSeries } from './useImportSeries'; import styles from './ImportSeriesFooter.css'; type MixedType = 'mixed'; function ImportSeriesFooter() { - const dispatch = useDispatch(); const { monitor: defaultMonitor, qualityProfileId: defaultQualityProfileId, @@ -38,9 +38,8 @@ function ImportSeriesFooter() { seasonFolder: defaultSeasonFolder, } = useAddSeriesOptions(); - const { isLookingUpSeries, isImporting, items, importError } = useSelector( - (state: AppState) => state.importSeries - ); + const items = useImportSeriesItems(); + const isLookingUpSeries = useLookupQueueHasItems(); const [monitor, setMonitor] = useState( defaultMonitor @@ -55,7 +54,9 @@ function ImportSeriesFooter() { defaultSeasonFolder ); - const { selectedCount, getSelectedIds } = useSelect(); + const { selectedCount, getSelectedIds } = useSelect(); + + const { importSeries, isImporting, importError } = useImportSeries(); const { hasUnsearchedItems, @@ -87,7 +88,7 @@ function ImportSeriesFooter() { isSeasonFolderMixed = true; } - if (!item.isPopulated) { + if (!item.hasSearched) { hasUnsearchedItems = true; } }); @@ -123,29 +124,26 @@ function ImportSeriesFooter() { setAddSeriesOption(name as keyof AddSeriesOptions, value); getSelectedIds().forEach((id) => { - dispatch( - // @ts-expect-error - actions are not typed - setImportSeriesValue({ - id, - [name]: value, - }) - ); + updateImportSeriesItem({ + id, + [name]: value, + }); }); }, - [getSelectedIds, dispatch] + [getSelectedIds] ); const handleLookupPress = useCallback(() => { - dispatch(lookupUnsearchedSeries()); - }, [dispatch]); + startProcessing(); + }, []); const handleCancelLookupPress = useCallback(() => { - dispatch(cancelLookupSeries()); - }, [dispatch]); + stopProcessing(); + }, []); const handleImportPress = useCallback(() => { - dispatch(importSeries({ ids: getSelectedIds() })); - }, [getSelectedIds, dispatch]); + importSeries(getSelectedIds()); + }, [importSeries, getSelectedIds]); useEffect(() => { if (isMonitorMixed && monitor !== 'mixed') { @@ -286,12 +284,12 @@ function ImportSeriesFooter() { title={translate('ImportErrors')} body={
    - {Array.isArray(importError.responseJSON) ? ( - importError.responseJSON.map((error, index) => { + {Array.isArray(importError.statusBody) ? ( + importError.statusBody.map((error, index) => { return
  • {error.errorMessage}
  • ; }) ) : ( -
  • {JSON.stringify(importError.responseJSON)}
  • +
  • {JSON.stringify(importError.statusBody)}
  • )}
} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx index 294a1f3db..c2cfbfc2d 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.tsx @@ -1,39 +1,29 @@ import React, { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; import { useSelect } from 'App/Select/SelectContext'; -import AppState from 'App/State/AppState'; -import { ImportSeries } from 'App/State/ImportSeriesAppState'; import FormInputGroup from 'Components/Form/FormInputGroup'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; import { inputTypes } from 'Helpers/Props'; import useExistingSeries from 'Series/useExistingSeries'; -import { setImportSeriesValue } from 'Store/Actions/importSeriesActions'; import { InputChanged } from 'typings/inputs'; import { SelectStateInputProps } from 'typings/props'; +import { + ImportSeriesItem, + UnamppedFolderItem, + updateImportSeriesItem, + useImportSeriesItem, +} from './importSeriesStore'; import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries'; import styles from './ImportSeriesRow.css'; -function createItemSelector(id: string) { - return createSelector( - (state: AppState) => state.importSeries.items, - (items) => { - return ( - items.find((item) => { - return item.id === id; - }) || ({} as ImportSeries) - ); - } - ); -} - interface ImportSeriesRowProps { - id: string; + unmappedFolder: UnamppedFolderItem; } -function ImportSeriesRow({ id }: ImportSeriesRowProps) { - const dispatch = useDispatch(); +function ImportSeriesRow({ unmappedFolder }: ImportSeriesRowProps) { + const id = unmappedFolder.id; + + const item = useImportSeriesItem(unmappedFolder.id); const { relativePath, @@ -42,24 +32,18 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) { seasonFolder, seriesType, selectedSeries, - } = useSelector(createItemSelector(id)); + } = item ?? {}; const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId); const { getIsSelected, toggleSelected, toggleDisabled } = - useSelect(); + useSelect(); const handleInputChange = useCallback( ({ name, value }: InputChanged) => { - dispatch( - // @ts-expect-error - actions are not typed - setImportSeriesValue({ - id, - [name]: value, - }) - ); + updateImportSeriesItem({ id, [name]: value }); }, - [id, dispatch] + [id] ); const handleSelectedChange = useCallback( @@ -77,6 +61,10 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) { toggleDisabled(id, !selectedSeries || isExistingSeries); }, [id, selectedSeries, isExistingSeries, toggleDisabled]); + useEffect(() => { + toggleSelected({ id, isSelected: !!selectedSeries, shiftKey: false }); + }, [id, selectedSeries, toggleSelected]); + return ( <> diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx index 21dd47e53..2a109e340 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.tsx @@ -1,33 +1,25 @@ -import React, { RefObject, useCallback, useEffect, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { RefObject, useCallback, useRef } from 'react'; import { FixedSizeList, ListChildComponentProps } from 'react-window'; -import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore'; import { useAppDimension } from 'App/appStore'; import { useSelect } from 'App/Select/SelectContext'; -import AppState from 'App/State/AppState'; -import { ImportSeries } from 'App/State/ImportSeriesAppState'; import VirtualTable from 'Components/Table/VirtualTable'; -import usePrevious from 'Helpers/Hooks/usePrevious'; -import useSeries from 'Series/useSeries'; -import { - queueLookupSeries, - setImportSeriesValue, -} from 'Store/Actions/importSeriesActions'; import { CheckInputChanged } from 'typings/inputs'; -import { SelectStateInputProps } from 'typings/props'; -import { UnmappedFolder } from 'typings/RootFolder'; import ImportSeriesHeader from './ImportSeriesHeader'; import ImportSeriesRow from './ImportSeriesRow'; +import { + UnamppedFolderItem, + useEnsureImportSeriesItems, +} from './importSeriesStore'; import styles from './ImportSeriesTable.css'; const ROW_HEIGHT = 52; interface RowItemData { - items: ImportSeries[]; + items: UnamppedFolderItem[]; } interface ImportSeriesTableProps { - unmappedFolders: UnmappedFolder[]; + items: UnamppedFolderItem[]; scrollerRef: RefObject; } @@ -49,42 +41,17 @@ function Row({ index, style, data }: ListChildComponentProps) { }} className={styles.row} > - + ); } -function ImportSeriesTable({ - unmappedFolders, - scrollerRef, -}: ImportSeriesTableProps) { - const dispatch = useDispatch(); - - const { monitor, qualityProfileId, seriesType, seasonFolder } = - useAddSeriesOptions(); - - const items = useSelector((state: AppState) => state.importSeries.items); +function ImportSeriesTable({ items, scrollerRef }: ImportSeriesTableProps) { const isSmallScreen = useAppDimension('isSmallScreen'); - const { data: allSeries } = useSeries(); - const { - allSelected, - allUnselected, - getIsSelected, - toggleSelected, - selectAll, - unselectAll, - } = useSelect(); - - const defaultValues = useRef({ - monitor, - qualityProfileId, - seriesType, - seasonFolder, - }); + const { allSelected, allUnselected, selectAll, unselectAll, useHasItems } = + useSelect(); const listRef = useRef>(null); - const initialUnmappedFolders = useRef(unmappedFolders); - const previousItems = usePrevious(items); const handleSelectAllChange = useCallback( ({ value }: CheckInputChanged) => { @@ -97,95 +64,11 @@ function ImportSeriesTable({ [selectAll, unselectAll] ); - const handleSelectedChange = useCallback( - ({ id, value, shiftKey }: SelectStateInputProps) => { - toggleSelected({ - id, - isSelected: value, - shiftKey, - }); - }, - [toggleSelected] - ); + const hasSelectItems = useHasItems(); - // TODO: Check if this is still needed - const handleRemoveSelectedStateItem = useCallback((_id: string) => { - // selectDispatch({ - // type: 'removeItem', - // id, - // }); - }, []); + useEnsureImportSeriesItems(items); - useEffect(() => { - initialUnmappedFolders.current.forEach(({ name, path, relativePath }) => { - dispatch( - queueLookupSeries({ - name, - path, - relativePath, - term: name, - }) - ); - - dispatch( - // @ts-expect-error - actions are not typed - setImportSeriesValue({ - id: name, - ...defaultValues.current, - }) - ); - }); - }, [dispatch]); - - useEffect(() => { - previousItems?.forEach((prevItem) => { - const { id } = prevItem; - - const item = items.find((i) => i.id === id); - - if (!item) { - handleRemoveSelectedStateItem(id); - return; - } - - const selectedSeries = item.selectedSeries; - const isSelected = getIsSelected(id); - - const isExistingSeries = - !!selectedSeries && - allSeries.some((s) => s.tvdbId === selectedSeries.tvdbId); - - if ( - (!selectedSeries && prevItem.selectedSeries) || - (isExistingSeries && !prevItem.selectedSeries) - ) { - handleSelectedChange({ id, value: false, shiftKey: false }); - - return; - } - - if (isSelected && (!selectedSeries || isExistingSeries)) { - handleSelectedChange({ id, value: false, shiftKey: false }); - - return; - } - - if (selectedSeries && selectedSeries !== prevItem.selectedSeries) { - handleSelectedChange({ id, value: true, shiftKey: false }); - - return; - } - }); - }, [ - allSeries, - items, - previousItems, - handleRemoveSelectedStateItem, - handleSelectedChange, - getIsSelected, - ]); - - if (!items.length) { + if (!items.length || !hasSelectItems) { return null; } diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.tsx b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.tsx index b46a47a51..06e7cdd85 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.tsx +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.tsx @@ -7,23 +7,27 @@ import { useFloating, useInteractions, } from '@floating-ui/react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import useImportSeriesItem from 'AddSeries/ImportSeries/Import/useImportSeriesItem'; -import AppState from 'App/State/AppState'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useLookupSeries } from 'AddSeries/AddNewSeries/useAddSeries'; import FormInputButton from 'Components/Form/FormInputButton'; import TextInput from 'Components/Form/TextInput'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import useDebounce from 'Helpers/Hooks/useDebounce'; import { icons, kinds } from 'Helpers/Props'; -import { - queueLookupSeries, - setImportSeriesValue, -} from 'Store/Actions/importSeriesActions'; +import useExistingSeries from 'Series/useExistingSeries'; import { InputChanged } from 'typings/inputs'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; +import { + addToLookupQueue, + removeFromLookupQueue, + updateImportSeriesItem, + useImportSeriesItem, + useIsCurrentedItemQueued, + useIsCurrentLookupQueueItem, +} from '../importSeriesStore'; import ImportSeriesSearchResult from './ImportSeriesSearchResult'; import ImportSeriesTitle from './ImportSeriesTitle'; import styles from './ImportSeriesSelectSeries.css'; @@ -37,28 +41,23 @@ function ImportSeriesSelectSeries({ id, onInputChange, }: ImportSeriesSelectSeriesProps) { - const dispatch = useDispatch(); - const isLookingUpSeries = useSelector( - (state: AppState) => state.importSeries.isLookingUpSeries + const importSeriesItem = useImportSeriesItem(id); + const { selectedSeries, name } = importSeriesItem ?? {}; + const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId); + + const [term, setTerm] = useState(name); + const [isOpen, setIsOpen] = useState(false); + const query = useDebounce(term, term ? 300 : 0); + const isCurrentLookupQueueItem = useIsCurrentLookupQueueItem(id); + const isQueued = useIsCurrentedItemQueued(id); + + const { isFetching, isFetched, error, data, refetch } = useLookupSeries( + query, + isCurrentLookupQueueItem ); - const { - error, - isFetching = true, - isPopulated = false, - items = [], - isQueued = true, - selectedSeries, - isExistingSeries, - term: itemTerm, - } = useImportSeriesItem(id); - - const seriesLookupTimeout = useRef>(); - - const [term, setTerm] = useState(''); - const [isOpen, setIsOpen] = useState(false); - const errorMessage = getErrorMessage(error); + const isLookingUpSeries = isFetching || isQueued; const handlePress = useCallback(() => { setIsOpen((prevIsOpen) => !prevIsOpen); @@ -66,48 +65,26 @@ function ImportSeriesSelectSeries({ const handleSearchInputChange = useCallback( ({ value }: InputChanged) => { - if (seriesLookupTimeout.current) { - clearTimeout(seriesLookupTimeout.current); - } - setTerm(value); - - seriesLookupTimeout.current = setTimeout(() => { - dispatch( - queueLookupSeries({ - name: id, - term: value, - topOfQueue: true, - }) - ); - }, 200); + addToLookupQueue(id); }, - [id, dispatch] + [id] ); const handleRefreshPress = useCallback(() => { - dispatch( - queueLookupSeries({ - name: id, - term, - topOfQueue: true, - }) - ); - }, [id, term, dispatch]); + refetch(); + }, [refetch]); const handleSeriesSelect = useCallback( (tvdbId: number) => { setIsOpen(false); - const selectedSeries = items.find((item) => item.tvdbId === tvdbId)!; + const selectedSeries = data.find((item) => item.tvdbId === tvdbId)!; - dispatch( - // @ts-expect-error - actions are not typed - setImportSeriesValue({ - id, - selectedSeries, - }) - ); + updateImportSeriesItem({ + id, + selectedSeries, + }); if (selectedSeries.seriesType !== 'standard') { onInputChange({ @@ -116,12 +93,24 @@ function ImportSeriesSelectSeries({ }); } }, - [id, items, dispatch, onInputChange] + [id, data, onInputChange] ); useEffect(() => { - setTerm(itemTerm); - }, [itemTerm]); + if (isFetched) { + updateImportSeriesItem({ + id, + hasSearched: isFetched, + selectedSeries: data[0], + }); + + removeFromLookupQueue(id); + } + }, [id, isFetched, data]); + + useEffect(() => { + setTerm(name); + }, [name]); const { refs, context, floatingStyles } = useFloating({ middleware: [ @@ -148,11 +137,11 @@ function ImportSeriesSelectSeries({ <>
- {isLookingUpSeries && isQueued && !isPopulated ? ( + {isLookingUpSeries && isQueued && !isFetched ? ( ) : null} - {isPopulated && selectedSeries && isExistingSeries ? ( + {isFetched && selectedSeries && isExistingSeries ? ( ) : null} - {isPopulated && selectedSeries ? ( + {isFetched && selectedSeries ? ( ) : null} - {isPopulated && !selectedSeries ? ( + {isFetched && !selectedSeries ? (
+ {isOpen ? (
- {items.map((item) => { + {data.map((item) => { return ( ; + lookupQueue: string[]; + isProcessing: boolean; +} + +const defaultState: ImportSeriesState = { + items: {}, + lookupQueue: [], + isProcessing: false, +}; + +const importSeriesStore = create()(() => defaultState); + +export const useEnsureImportSeriesItems = ( + unmappedFolders: UnamppedFolderItem[] +) => { + const { monitor, qualityProfileId, seriesType, seasonFolder } = + useAddSeriesOptions(); + + useEffect(() => { + unmappedFolders.forEach((unmappedFolder) => { + const existingItem = + importSeriesStore.getState().items[unmappedFolder.id]; + + if (existingItem) { + return; + } + + const newItem: ImportSeriesItem = { + ...unmappedFolder, + monitor, + qualityProfileId, + seriesType, + seasonFolder, + hasSearched: false, + }; + + importSeriesStore.setState((state) => ({ + items: { + ...state.items, + [unmappedFolder.id]: newItem, + }, + })); + }); + }, [unmappedFolders, monitor, qualityProfileId, seriesType, seasonFolder]); +}; + +export const updateImportSeriesItem = ( + itemData: Partial & Pick +) => { + console.info('\x1b[36m[MarkTest] updating item\x1b[0m', itemData); + + importSeriesStore.setState((state) => { + const existingItem = state.items[itemData.id]; + + if (existingItem) { + return { + items: { + ...state.items, + [itemData.id]: { + ...existingItem, + ...itemData, + }, + }, + }; + } + + return state; + }); +}; + +export const removeImportSeriesItemByPath = (path: string) => { + importSeriesStore.setState((state) => { + const item = Object.values(state.items).find((i) => i.path === path); + + if (!item) { + return state; + } + + const { [item.id]: removed, ...items } = state.items; + + return { items }; + }); +}; + +export const clearImportSeries = () => { + importSeriesStore.setState(defaultState); +}; + +export const startProcessing = () => { + importSeriesStore.setState((state) => { + const items = Object.values(state.items).reduce((acc, item) => { + if (!item.hasSearched) { + acc.push(item.id); + } + + return acc; + }, []); + + return { isProcessing: true, lookupQueue: items }; + }); +}; + +export const stopProcessing = () => { + importSeriesStore.setState({ isProcessing: false, lookupQueue: [] }); +}; + +export const addToLookupQueue = (id: string) => { + importSeriesStore.setState((state) => ({ + lookupQueue: [...state.lookupQueue, id], + })); +}; + +export const removeFromLookupQueue = (id: string) => { + importSeriesStore.setState((state) => ({ + lookupQueue: state.lookupQueue.filter((queuedId) => queuedId !== id), + })); +}; + +export const useIsCurrentLookupQueueItem = (id: string) => { + return importSeriesStore((state) => state.lookupQueue[0] === id); +}; + +export const useIsCurrentedItemQueued = (id: string) => { + return importSeriesStore((state) => state.lookupQueue.includes(id)); +}; + +export const useLookupQueueHasItems = () => { + return importSeriesStore((state) => state.lookupQueue.length > 0); +}; + +export const useImportSeriesItem = (id: string) => { + return importSeriesStore((state) => state.items[id]); +}; + +export const useImportSeriesItems = () => { + return importSeriesStore(useShallow((state) => Object.values(state.items))); +}; + +export const getImportSeriesItems = (ids: string[]) => { + const state = importSeriesStore.getState(); + + return ids.reduce((acc, id) => { + const item = state.items[id]; + + if (item != null) { + acc.push(item); + } + + return acc; + }, []); +}; diff --git a/frontend/src/AddSeries/ImportSeries/Import/useImportSeries.ts b/frontend/src/AddSeries/ImportSeries/Import/useImportSeries.ts new file mode 100644 index 000000000..1c0712f50 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/useImportSeries.ts @@ -0,0 +1,85 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import Series from 'Series/Series'; +import { + getImportSeriesItems, + removeImportSeriesItemByPath, +} from './importSeriesStore'; + +export const useImportSeries = () => { + const queryClient = useQueryClient(); + + const { isPending, error, mutate } = useApiMutation({ + path: '/series/import', + method: 'POST', + mutationOptions: { + onSuccess: (data, newSeries) => { + queryClient.invalidateQueries({ queryKey: ['/rootFolder'] }); + queryClient.setQueryData(['/series'], (oldSeries) => { + if (!oldSeries) { + return data; + } + + return [...oldSeries, ...data]; + }); + + newSeries.forEach((series) => { + removeImportSeriesItemByPath(series.path); + }); + }, + }, + }); + + const importSeries = useCallback( + (ids: string[]) => { + const items = getImportSeriesItems(ids); + const addedIds: string[] = []; + + const allNewSeries = ids.reduce((acc, id) => { + const item = items.find((i) => i.id === id); + const selectedSeries = item?.selectedSeries; + + // Make sure we have a selected series and the same series hasn't been added yet. + if ( + selectedSeries && + !acc.some((a) => a.tvdbId === selectedSeries.tvdbId) + ) { + const newSeries: Series = { + ...selectedSeries, + monitored: true, + monitorNewItems: 'all', + qualityProfileId: item.qualityProfileId, + path: item.path, + seriesType: item.seriesType, + seasonFolder: item.seasonFolder, + addOptions: { + monitor: item.monitor, + searchForMissingEpisodes: false, + searchForCutoffUnmetEpisodes: false, + }, + tags: [], + }; + + newSeries.path = item.path; + + addedIds.push(id); + acc.push(newSeries); + } + + return acc; + }, []); + + if (allNewSeries.length > 0) { + mutate(allNewSeries); + } + }, + [mutate] + ); + + return { + isImporting: isPending, + importError: error, + importSeries, + }; +}; diff --git a/frontend/src/AddSeries/ImportSeries/Import/useImportSeriesItem.ts b/frontend/src/AddSeries/ImportSeries/Import/useImportSeriesItem.ts deleted file mode 100644 index e9560e7bb..000000000 --- a/frontend/src/AddSeries/ImportSeries/Import/useImportSeriesItem.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; -import { ImportSeries } from 'App/State/ImportSeriesAppState'; -import useSeries from 'Series/useSeries'; - -function useImportSeriesItem(id: string) { - const { data: series = [] } = useSeries(); - const importSeries = useSelector((state: AppState) => state.importSeries); - - return useMemo(() => { - const item = - importSeries.items.find((item) => { - return item.id === id; - }) ?? ({} as ImportSeries); - - const selectedSeries = item && item.selectedSeries; - const isExistingSeries = - !!selectedSeries && - series.some((s) => { - return s.tvdbId === selectedSeries.tvdbId; - }); - - return { - ...item, - isExistingSeries, - }; - }, [id, importSeries.items, series]); -} - -export default useImportSeriesItem; diff --git a/frontend/src/App/Select/useSelectStore.ts b/frontend/src/App/Select/useSelectStore.ts index f1b8b88dd..a793a7b56 100644 --- a/frontend/src/App/Select/useSelectStore.ts +++ b/frontend/src/App/Select/useSelectStore.ts @@ -214,6 +214,15 @@ export default function useSelectStore>( ); }; + const useHasItems = () => { + return useStore( + store.current, + useShallow((state) => { + return state.itemState.size > 0; + }) + ); + }; + useEffect(() => { const unsubscribe = store.current.subscribe((state) => { const itemState = state.itemState; @@ -231,7 +240,9 @@ export default function useSelectStore>( return acc; }, { - allSelected: itemState.size > 0, + allSelected: + itemState.size > 0 && + itemState.values().some((i) => i.isSelected), allUnselected: true, anySelected: false, selectedCount: 0, @@ -296,6 +307,7 @@ export default function useSelectStore>( toggleDisabled, toggleSelected, unselectAll, + useHasItems, useIsSelected, useSelectedIds, }; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index e6f26648a..90cc0affd 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,10 +1,8 @@ import CaptchaAppState from './CaptchaAppState'; -import ImportSeriesAppState from './ImportSeriesAppState'; import SettingsAppState from './SettingsAppState'; interface AppState { captcha: CaptchaAppState; - importSeries: ImportSeriesAppState; settings: SettingsAppState; } diff --git a/frontend/src/App/State/ImportSeriesAppState.ts b/frontend/src/App/State/ImportSeriesAppState.ts deleted file mode 100644 index f4bf4b56a..000000000 --- a/frontend/src/App/State/ImportSeriesAppState.ts +++ /dev/null @@ -1,29 +0,0 @@ -import Series, { SeriesMonitor, SeriesType } from 'Series/Series'; -import { Error } from './AppSectionState'; - -export interface ImportSeries { - id: string; - error?: Error; - isFetching: boolean; - isPopulated: boolean; - isQueued: boolean; - items: Series[]; - monitor: SeriesMonitor; - path: string; - qualityProfileId: number; - relativePath: string; - seasonFolder: boolean; - selectedSeries?: Series; - seriesType: SeriesType; - term: string; -} - -interface ImportSeriesAppState { - isLookingUpSeries: false; - isImporting: false; - isImported: false; - importError: Error | null; - items: ImportSeries[]; -} - -export default ImportSeriesAppState; diff --git a/frontend/src/InteractiveImport/useInteractiveImport.ts b/frontend/src/InteractiveImport/useInteractiveImport.ts index a35f9fca4..ae075e3d2 100644 --- a/frontend/src/InteractiveImport/useInteractiveImport.ts +++ b/frontend/src/InteractiveImport/useInteractiveImport.ts @@ -165,7 +165,6 @@ export const useReprocessInteractiveImportItems = () => { })[0]; if (!currentData) { - console.info('\x1b[36m[MarkTest] no data\x1b[0m'); return; } diff --git a/frontend/src/RootFolder/useRootFolders.ts b/frontend/src/RootFolder/useRootFolders.ts index e7f9ba40e..526dccf36 100644 --- a/frontend/src/RootFolder/useRootFolders.ts +++ b/frontend/src/RootFolder/useRootFolders.ts @@ -36,14 +36,18 @@ const useRootFolders = () => { }; export const useRootFolder = (id: number, timeout: boolean) => { - const result = useApiQuery({ + const result = useApiQuery({ path: `/rootFolder/${id}`, queryParams: { timeout }, + queryOptions: { + // Disable refetch on window focus to prevent refetching when the user switch tabs + refetchOnWindowFocus: false, + }, }); return { ...result, - data: result.data ?? DEFAULT_ROOT_FOLDERS, + data: result.data, }; }; diff --git a/frontend/src/Store/Actions/importSeriesActions.js b/frontend/src/Store/Actions/importSeriesActions.js deleted file mode 100644 index 56e6719c9..000000000 --- a/frontend/src/Store/Actions/importSeriesActions.js +++ /dev/null @@ -1,336 +0,0 @@ -import _ from 'lodash'; -import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import getNewSeries from 'Utilities/Series/getNewSeries'; -import * as seriesTypes from 'Utilities/Series/seriesTypes'; -import getSectionState from 'Utilities/State/getSectionState'; -import updateSectionState from 'Utilities/State/updateSectionState'; -import { removeItem, set, updateItem } from './baseActions'; -import createHandleActions from './Creators/createHandleActions'; - -// -// Variables - -export const section = 'importSeries'; -let concurrentLookups = 0; -let abortCurrentLookup = null; -const queue = []; - -// -// State - -export const defaultState = { - isLookingUpSeries: false, - isImporting: false, - isImported: false, - importError: null, - items: [] -}; - -// -// Actions Types - -export const QUEUE_LOOKUP_SERIES = 'importSeries/queueLookupSeries'; -export const START_LOOKUP_SERIES = 'importSeries/startLookupSeries'; -export const CANCEL_LOOKUP_SERIES = 'importSeries/cancelLookupSeries'; -export const LOOKUP_UNSEARCHED_SERIES = 'importSeries/lookupUnsearchedSeries'; -export const CLEAR_IMPORT_SERIES = 'importSeries/clearImportSeries'; -export const SET_IMPORT_SERIES_VALUE = 'importSeries/setImportSeriesValue'; -export const IMPORT_SERIES = 'importSeries/importSeries'; - -// -// Action Creators - -export const queueLookupSeries = createThunk(QUEUE_LOOKUP_SERIES); -export const startLookupSeries = createThunk(START_LOOKUP_SERIES); -export const importSeries = createThunk(IMPORT_SERIES); -export const lookupUnsearchedSeries = createThunk(LOOKUP_UNSEARCHED_SERIES); -export const clearImportSeries = createAction(CLEAR_IMPORT_SERIES); -export const cancelLookupSeries = createAction(CANCEL_LOOKUP_SERIES); - -export const setImportSeriesValue = createAction(SET_IMPORT_SERIES_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - - [QUEUE_LOOKUP_SERIES]: function(getState, payload, dispatch) { - const { - name, - path, - relativePath, - term, - topOfQueue = false - } = payload; - - const state = getState().importSeries; - const item = _.find(state.items, { id: name }) || { - id: name, - term, - path, - relativePath, - isFetching: false, - isPopulated: false, - error: null - }; - - dispatch(updateItem({ - section, - ...item, - term, - isQueued: true, - items: [] - })); - - const itemIndex = queue.indexOf(item.id); - - if (itemIndex >= 0) { - queue.splice(itemIndex, 1); - } - - if (topOfQueue) { - queue.unshift(item.id); - } else { - queue.push(item.id); - } - - if (term && term.length > 2) { - dispatch(startLookupSeries({ start: true })); - } - }, - - [START_LOOKUP_SERIES]: function(getState, payload, dispatch) { - if (concurrentLookups >= 1) { - return; - } - - const state = getState().importSeries; - - const { - isLookingUpSeries, - items - } = state; - - const queueId = queue[0]; - - if (payload.start && !isLookingUpSeries) { - dispatch(set({ section, isLookingUpSeries: true })); - } else if (!isLookingUpSeries) { - return; - } else if (!queueId) { - dispatch(set({ section, isLookingUpSeries: false })); - return; - } - - concurrentLookups++; - queue.splice(0, 1); - - const queued = items.find((i) => i.id === queueId); - - dispatch(updateItem({ - section, - id: queued.id, - isFetching: true - })); - - const { request, abortRequest } = createAjaxRequest({ - url: '/series/lookup', - data: { - term: queued.term - } - }); - - abortCurrentLookup = abortRequest; - - request.done((data) => { - const selectedSeries = queued.selectedSeries || data[0]; - - const itemProps = { - section, - id: queued.id, - isFetching: false, - isPopulated: true, - error: null, - items: data, - isQueued: false, - selectedSeries, - updateOnly: true - }; - - if (selectedSeries && selectedSeries.seriesType !== seriesTypes.STANDARD) { - itemProps.seriesType = selectedSeries.seriesType; - } - - dispatch(updateItem(itemProps)); - }); - - request.fail((xhr) => { - dispatch(updateItem({ - section, - id: queued.id, - isFetching: false, - isPopulated: false, - error: xhr, - isQueued: false, - updateOnly: true - })); - }); - - request.always(() => { - concurrentLookups--; - - dispatch(startLookupSeries()); - }); - }, - - [LOOKUP_UNSEARCHED_SERIES]: function(getState, payload, dispatch) { - const state = getState().importSeries; - - if (state.isLookingUpSeries) { - return; - } - - state.items.forEach((item) => { - const id = item.id; - - if ( - !item.isPopulated && - !queue.includes(id) - ) { - queue.push(item.id); - } - }); - - if (queue.length) { - dispatch(startLookupSeries({ start: true })); - } - }, - - [IMPORT_SERIES]: function(getState, payload, dispatch) { - dispatch(set({ section, isImporting: true })); - - const ids = payload.ids; - const items = getState().importSeries.items; - const addedIds = []; - - const allNewSeries = ids.reduce((acc, id) => { - const item = items.find((i) => i.id === id); - const selectedSeries = item.selectedSeries; - - // Make sure we have a selected series and - // the same series hasn't been added yet. - if (selectedSeries && !acc.some((a) => a.tvdbId === selectedSeries.tvdbId)) { - const newSeries = getNewSeries(_.cloneDeep(selectedSeries), item); - newSeries.path = item.path; - - addedIds.push(id); - acc.push(newSeries); - } - - return acc; - }, []); - - const promise = createAjaxRequest({ - url: '/series/import', - method: 'POST', - contentType: 'application/json', - data: JSON.stringify(allNewSeries) - }).request; - - promise.done((data) => { - dispatch(batchActions([ - set({ - section, - isImporting: false, - isImported: true, - importError: null - }), - - ...data.map((series) => updateItem({ section: 'series', ...series })), - - ...addedIds.map((id) => removeItem({ section, id })) - ])); - }); - - promise.fail((xhr) => { - dispatch(batchActions([ - set({ - section, - isImporting: false, - isImported: true, - importError: xhr - }), - - ...addedIds.map((id) => updateItem({ - section, - id - })) - ])); - }); - } -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [CANCEL_LOOKUP_SERIES]: function(state) { - queue.splice(0, queue.length); - - const items = state.items.map((item) => { - if (item.isQueued) { - return { - ...item, - isQueued: false - }; - } - - return item; - }); - - return Object.assign({}, state, { - isLookingUpSeries: false, - items - }); - }, - - [CLEAR_IMPORT_SERIES]: function(state) { - if (abortCurrentLookup) { - abortCurrentLookup(); - - abortCurrentLookup = null; - } - - queue.splice(0, queue.length); - - return Object.assign({}, state, defaultState); - }, - - [SET_IMPORT_SERIES_VALUE]: function(state, { payload }) { - const newState = getSectionState(state, section); - const items = newState.items; - const index = items.findIndex((item) => item.id === payload.id); - - newState.items = [...items]; - - if (index >= 0) { - const item = items[index]; - - newState.items.splice(index, 1, { ...item, ...payload }); - } else { - newState.items.push({ ...payload }); - } - - return updateSectionState(state, section, newState); - } - -}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index d316e35ab..1723571da 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -1,9 +1,7 @@ import * as captcha from './captchaActions'; -import * as importSeries from './importSeriesActions'; import * as settings from './settingsActions'; export default [ captcha, - importSeries, settings ];