From dec6f4b5f24ee05ea911643432aea84dd5e89e93 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 2 Dec 2025 20:31:24 -0800 Subject: [PATCH] Use react-query for commands --- frontend/src/Activity/Blocklist/Blocklist.tsx | 25 +- frontend/src/Activity/Queue/Queue.tsx | 22 +- frontend/src/App/State/AppState.ts | 2 - frontend/src/App/State/CommandAppState.ts | 6 - frontend/src/Calendar/Calendar.tsx | 12 +- .../CalendarMissingEpisodeSearchButton.tsx | 30 +-- frontend/src/Calendar/CalendarPage.tsx | 22 +- frontend/src/Calendar/useCalendar.ts | 18 +- frontend/src/Commands/Command.ts | 95 ++++++- frontend/src/Commands/CommandNames.ts | 25 ++ frontend/src/Commands/commandNames.js | 21 -- frontend/src/Commands/useCommands.ts | 240 ++++++++++++++++++ frontend/src/Components/SignalRListener.tsx | 23 +- frontend/src/Episode/EpisodeSearchCell.tsx | 26 +- frontend/src/Episode/Search/EpisodeSearch.tsx | 19 +- frontend/src/Helpers/Hooks/useAppPage.ts | 3 + ...eractiveImportSelectFolderModalContent.tsx | 18 +- .../InteractiveImportModalContent.tsx | 19 +- .../Organize/OrganizePreviewModalContent.tsx | 19 +- frontend/src/Series/Details/SeriesDetails.tsx | 56 ++-- .../Series/Details/SeriesDetailsSeason.tsx | 32 +-- .../Index/Overview/SeriesIndexOverview.tsx | 99 ++++---- .../Index/Posters/SeriesIndexPoster.tsx | 84 +++--- .../Organize/OrganizeSeriesModalContent.tsx | 19 +- .../Index/Select/SeriesIndexSelectFooter.tsx | 9 +- frontend/src/Series/Index/SeriesIndex.tsx | 22 +- .../Index/SeriesIndexRefreshSeriesButton.tsx | 27 +- .../src/Series/Index/Table/SeriesIndexRow.tsx | 103 ++++---- .../src/Series/Index/useSeriesIndexItem.ts | 58 ++--- .../src/Settings/General/GeneralSettings.tsx | 8 +- .../src/Settings/General/SecuritySettings.tsx | 13 +- frontend/src/Settings/Quality/Quality.tsx | 9 +- .../ResetQualityDefinitionsModalContent.tsx | 25 +- frontend/src/Store/Actions/commandActions.js | 219 ---------------- frontend/src/Store/Actions/index.js | 2 - .../createCommandExecutingSelector.ts | 11 - .../Store/Selectors/createCommandSelector.ts | 11 - .../Store/Selectors/createCommandsSelector.ts | 13 - .../createExecutingCommandsSelector.ts | 14 - frontend/src/System/Backup/Backups.tsx | 22 +- frontend/src/System/Events/LogsTable.tsx | 28 +- frontend/src/System/Logs/App/AppLogFiles.tsx | 28 +- .../src/System/Logs/Update/UpdateLogFiles.tsx | 28 +- .../src/System/Tasks/Queued/QueuedTaskRow.tsx | 10 +- .../Tasks/Queued/QueuedTaskRowNameCell.tsx | 6 +- .../src/System/Tasks/Queued/QueuedTasks.tsx | 47 ++-- .../Tasks/Scheduled/ScheduledTaskRow.tsx | 13 +- frontend/src/System/Updates/Updates.tsx | 26 +- .../src/Utilities/Command/isSameCommand.ts | 31 +-- .../src/Wanted/CutoffUnmet/CutoffUnmet.tsx | 48 ++-- frontend/src/Wanted/Missing/Missing.tsx | 48 ++-- 51 files changed, 872 insertions(+), 942 deletions(-) delete mode 100644 frontend/src/App/State/CommandAppState.ts create mode 100644 frontend/src/Commands/CommandNames.ts delete mode 100644 frontend/src/Commands/commandNames.js create mode 100644 frontend/src/Commands/useCommands.ts delete mode 100644 frontend/src/Store/Actions/commandActions.js delete mode 100644 frontend/src/Store/Selectors/createCommandExecutingSelector.ts delete mode 100644 frontend/src/Store/Selectors/createCommandSelector.ts delete mode 100644 frontend/src/Store/Selectors/createCommandsSelector.ts delete mode 100644 frontend/src/Store/Selectors/createExecutingCommandsSelector.ts diff --git a/frontend/src/Activity/Blocklist/Blocklist.tsx b/frontend/src/Activity/Blocklist/Blocklist.tsx index d8e5cb6dc..b9c3a303e 100644 --- a/frontend/src/Activity/Blocklist/Blocklist.tsx +++ b/frontend/src/Activity/Blocklist/Blocklist.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { setQueueOptions } from 'Activity/Queue/queueOptionsStore'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FilterMenu from 'Components/Menu/FilterMenu'; @@ -19,8 +19,6 @@ import TablePager from 'Components/Table/TablePager'; import { useCustomFiltersList } from 'Filters/useCustomFilters'; import { align, icons, kinds } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import BlockListModel from 'typings/Blocklist'; import { CheckInputChanged } from 'typings/inputs'; import { TableOptionsChangePayload } from 'typings/Table'; @@ -62,10 +60,10 @@ function BlocklistContent() { const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems(); const customFilters = useCustomFiltersList('blocklist'); - const isClearingBlocklistExecuting = useSelector( - createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST) + const executeCommand = useExecuteCommand(); + const isClearingBlocklistExecuting = useCommandExecuting( + CommandNames.ClearBlocklist ); - const dispatch = useDispatch(); const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = useState(false); @@ -109,16 +107,11 @@ function BlocklistContent() { }, [setIsConfirmClearModalOpen]); const handleClearBlocklistConfirmed = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.CLEAR_BLOCKLIST, - commandFinished: () => { - goToPage(1); - }, - }) - ); + executeCommand({ name: CommandNames.ClearBlocklist }, () => { + goToPage(1); + }); setIsConfirmClearModalOpen(false); - }, [setIsConfirmClearModalOpen, goToPage, dispatch]); + }, [setIsConfirmClearModalOpen, goToPage, executeCommand]); const handleConfirmClearModalClose = useCallback(() => { setIsConfirmClearModalOpen(false); diff --git a/frontend/src/Activity/Queue/Queue.tsx b/frontend/src/Activity/Queue/Queue.tsx index e108aaa34..f5d3896d3 100644 --- a/frontend/src/Activity/Queue/Queue.tsx +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -6,9 +6,9 @@ import React, { useRef, useState, } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FilterMenu from 'Components/Menu/FilterMenu'; @@ -26,8 +26,6 @@ import useEpisodes from 'Episode/useEpisodes'; import { useCustomFiltersList } from 'Filters/useCustomFilters'; import { align, icons, kinds } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { CheckInputChanged } from 'typings/inputs'; import QueueModel from 'typings/Queue'; import { TableOptionsChangePayload } from 'typings/Table'; @@ -55,7 +53,7 @@ import useQueue, { } from './useQueue'; function QueueContent() { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { records, @@ -91,8 +89,8 @@ function QueueContent() { const customFilters = useCustomFiltersList('queue'); - const isRefreshMonitoredDownloadsExecuting = useSelector( - createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS) + const isRefreshMonitoredDownloadsExecuting = useCommandExecuting( + CommandNames.RefreshMonitoredDownloads ); const shouldBlockRefresh = useRef(false); @@ -136,12 +134,10 @@ function QueueContent() { ); const handleRefreshPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.REFRESH_MONITORED_DOWNLOADS, - }) - ); - }, [dispatch]); + executeCommand({ + name: CommandNames.RefreshMonitoredDownloads, + }); + }, [executeCommand]); const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => { shouldBlockRefresh.current = isOpen; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 71831fefc..1140f8cb3 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,6 +1,5 @@ import BlocklistAppState from './BlocklistAppState'; import CaptchaAppState from './CaptchaAppState'; -import CommandAppState from './CommandAppState'; import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState'; import ImportSeriesAppState from './ImportSeriesAppState'; import InteractiveImportAppState from './InteractiveImportAppState'; @@ -12,7 +11,6 @@ import SettingsAppState from './SettingsAppState'; interface AppState { blocklist: BlocklistAppState; captcha: CaptchaAppState; - commands: CommandAppState; episodeHistory: HistoryAppState; importSeries: ImportSeriesAppState; interactiveImport: InteractiveImportAppState; diff --git a/frontend/src/App/State/CommandAppState.ts b/frontend/src/App/State/CommandAppState.ts deleted file mode 100644 index 1bde37371..000000000 --- a/frontend/src/App/State/CommandAppState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import AppSectionState from 'App/State/AppSectionState'; -import Command from 'Commands/Command'; - -export type CommandAppState = AppSectionState; - -export default CommandAppState; diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx index f52c707b3..0f3f8f583 100644 --- a/frontend/src/Calendar/Calendar.tsx +++ b/frontend/src/Calendar/Calendar.tsx @@ -1,12 +1,11 @@ import React, { useCallback, useEffect, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds } from 'Helpers/Props'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { registerPagePopulator, unregisterPagePopulator, @@ -23,16 +22,13 @@ import styles from './Calendar.css'; const UPDATE_DELAY = 3600000; // 1 hour function Calendar() { - const dispatch = useDispatch(); const requestCurrentPage = useCurrentPage(); const updateTimeout = useRef>(); const { isFetching, isLoading, error, refetch } = useCalendar(); const view = useCalendarOption('view'); - const isRefreshingSeries = useSelector( - createCommandExecutingSelector(commandNames.REFRESH_SERIES) - ); + const isRefreshingSeries = useCommandExecuting(CommandNames.RefreshSeries); const wasRefreshingSeries = usePrevious(isRefreshingSeries); @@ -59,7 +55,7 @@ function Calendar() { if (!requestCurrentPage) { goToToday(); } - }, [requestCurrentPage, dispatch]); + }, [requestCurrentPage]); useEffect(() => { const repopulate = () => { diff --git a/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx b/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx index 66bf8903f..91aa6d666 100644 --- a/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx +++ b/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx @@ -1,11 +1,9 @@ import moment from 'moment'; import React, { useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider'; +import { useCommands } from 'Commands/useCommands'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import { icons } from 'Helpers/Props'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import { isCommandExecuting } from 'Utilities/Command'; import isBefore from 'Utilities/Date/isBefore'; import translate from 'Utilities/String/translate'; @@ -14,18 +12,18 @@ import useCalendar, { useCalendarSearchMissingCommandId, } from './useCalendar'; -function createIsSearchingSelector(searchMissingCommandId: number | undefined) { - return createSelector(createCommandsSelector(), (commands) => { - if (searchMissingCommandId == null) { - return false; - } +function useIsSearching(searchMissingCommandId: number | undefined) { + const { data: commands } = useCommands(); - return isCommandExecuting( - commands.find((command) => { - return command.id === searchMissingCommandId; - }) - ); - }); + if (searchMissingCommandId == null) { + return false; + } + + return isCommandExecuting( + commands.find((command) => { + return command.id === searchMissingCommandId; + }) + ); } const useMissingEpisodeIdsSelector = () => { @@ -55,9 +53,7 @@ const useMissingEpisodeIdsSelector = () => { export default function CalendarMissingEpisodeSearchButton() { const searchMissingCommandId = useCalendarSearchMissingCommandId(); const missingEpisodeIds = useMissingEpisodeIdsSelector(); - const isSearchingForMissing = useSelector( - createIsSearchingSelector(searchMissingCommandId) - ); + const isSearchingForMissing = useIsSearching(searchMissingCommandId); const handlePress = useCallback(() => {}, []); diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx index 3baa7eaab..a39a89f2a 100644 --- a/frontend/src/Calendar/CalendarPage.tsx +++ b/frontend/src/Calendar/CalendarPage.tsx @@ -5,9 +5,9 @@ import React, { useMemo, useState, } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import FilterMenu from 'Components/Menu/FilterMenu'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; @@ -22,8 +22,6 @@ import useMeasure from 'Helpers/Hooks/useMeasure'; import { align, icons } from 'Helpers/Props'; import NoSeries from 'Series/NoSeries'; import { useHasSeries } from 'Series/useSeries'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import translate from 'Utilities/String/translate'; import Calendar from './Calendar'; @@ -43,16 +41,14 @@ import styles from './CalendarPage.css'; const MINIMUM_DAY_WIDTH = 120; function CalendarPage() { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const selectedFilterKey = useCalendarOption('selectedFilterKey'); const { data } = useCalendar(); useCalendarPage(); - const isRssSyncExecuting = useSelector( - createCommandExecutingSelector(commandNames.RSS_SYNC) - ); + const isRssSyncExecuting = useCommandExecuting(CommandNames.RssSync); const customFilters = useCustomFiltersList('calendar'); const hasSeries = useHasSeries(); @@ -80,12 +76,10 @@ function CalendarPage() { }, []); const handleRssSyncPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.RSS_SYNC, - }) - ); - }, [dispatch]); + executeCommand({ + name: CommandNames.RssSync, + }); + }, [executeCommand]); const handleFilterSelect = useCallback((key: string | number) => { setCalendarOption('selectedFilterKey', key); diff --git a/frontend/src/Calendar/useCalendar.ts b/frontend/src/Calendar/useCalendar.ts index 5190feeca..f88a35db0 100644 --- a/frontend/src/Calendar/useCalendar.ts +++ b/frontend/src/Calendar/useCalendar.ts @@ -1,17 +1,14 @@ import { keepPreviousData } from '@tanstack/react-query'; import moment from 'moment'; import { useEffect, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { create } from 'zustand'; import AppState from 'App/State/AppState'; -import Command from 'Commands/Command'; -import * as commandNames from 'Commands/commandNames'; import { setEpisodeQueryKey } from 'Episode/useEpisode'; import { Filter, FilterBuilderProp } from 'Filters/Filter'; import { useCustomFiltersList } from 'Filters/useCustomFilters'; import useApiQuery from 'Helpers/Hooks/useApiQuery'; import { filterBuilderValueTypes } from 'Helpers/Props'; -import { executeCommandHelper } from 'Store/Actions/commandActions'; import { CalendarItem } from 'typings/Calendar'; import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import translate from 'Utilities/String/translate'; @@ -182,19 +179,6 @@ export const useCalendarSearchMissingCommandId = () => { return calendarStore((state) => state.searchMissingCommandId); }; -export const useSearchMissing = (episodeIds: number[]) => { - const dispatch = useDispatch(); - - const commandPayload = { - name: commandNames.EPISODE_SEARCH, - episodeIds, - }; - - executeCommandHelper(commandPayload, dispatch).then((data: Command) => { - calendarStore.setState({ searchMissingCommandId: data.id }); - }); -}; - export const setCalendarDayCount = (dayCount: number) => { calendarStore.setState({ dayCount }); }; diff --git a/frontend/src/Commands/Command.ts b/frontend/src/Commands/Command.ts index cd875d56b..42839c894 100644 --- a/frontend/src/Commands/Command.ts +++ b/frontend/src/Commands/Command.ts @@ -1,4 +1,5 @@ import ModelBase from 'App/ModelBase'; +import { InteractiveImportCommandOptions } from 'InteractiveImport/InteractiveImport'; export type CommandStatus = | 'queued' @@ -11,7 +12,8 @@ export type CommandStatus = export type CommandResult = 'unknown' | 'successful' | 'unsuccessful'; -export interface CommandBody { +// Base command body with common properties +export interface BaseCommandBody { sendUpdatesToClient: boolean; updateScheduledTask: boolean; completionMessage: string; @@ -23,13 +25,102 @@ export interface CommandBody { lastStartTime: string; trigger: string; suppressMessages: boolean; +} + +// Specific command body interfaces +export interface SeriesCommandBody extends BaseCommandBody { + seriesId: number; +} + +export interface MultipleSeriesCommandBody extends BaseCommandBody { + seriesIds: number[]; +} + +export interface SeasonCommandBody extends BaseCommandBody { + seriesId: number; + seasonNumber: number; +} + +export interface EpisodeCommandBody extends BaseCommandBody { + episodeIds: number[]; +} + +export interface SeriesEpisodeCommandBody extends BaseCommandBody { + seriesId: number; + episodeIds: number[]; +} + +export interface RenameFilesCommandBody extends BaseCommandBody { + seriesId: number; + files: number[]; +} + +export interface MoveSeriesCommandBody extends BaseCommandBody { + seriesId: number; + destinationPath: string; +} + +export interface ManualImportCommandBody extends BaseCommandBody { + files: Array<{ + path: string; + seriesId: number; + episodeIds: number[]; + quality: Record; + language: Record; + releaseGroup?: string; + }>; +} + +export type CommandBody = + | SeriesCommandBody + | MultipleSeriesCommandBody + | SeasonCommandBody + | EpisodeCommandBody + | SeriesEpisodeCommandBody + | RenameFilesCommandBody + | MoveSeriesCommandBody + | ManualImportCommandBody + | BaseCommandBody; + +// Simplified interface for creating new commands +export interface NewCommandBody { + name: string; seriesId?: number; seriesIds?: number[]; seasonNumber?: number; episodeIds?: number[]; - [key: string]: string | number | boolean | number[] | undefined; + files?: number[] | InteractiveImportCommandOptions[]; + destinationPath?: string; + [key: string]: string | number | boolean | number[] | object | undefined; } +export interface CommandBodyMap { + RefreshSeries: SeriesCommandBody | MultipleSeriesCommandBody; + SeriesSearch: SeriesCommandBody; + SeasonSearch: SeasonCommandBody; + EpisodeSearch: EpisodeCommandBody | SeriesEpisodeCommandBody; + MissingEpisodeSearch: BaseCommandBody; + CutoffUnmetEpisodeSearch: BaseCommandBody; + RenameFiles: RenameFilesCommandBody; + RenameSeries: MultipleSeriesCommandBody; + MoveSeries: MoveSeriesCommandBody; + ManualImport: ManualImportCommandBody; + DownloadedEpisodesScan: SeriesCommandBody | BaseCommandBody; + RssSync: BaseCommandBody; + ApplicationUpdate: BaseCommandBody; + Backup: BaseCommandBody; + ClearBlocklist: BaseCommandBody; + ClearLog: BaseCommandBody; + DeleteLogFiles: BaseCommandBody; + DeleteUpdateLogFiles: BaseCommandBody; + RefreshMonitoredDownloads: BaseCommandBody; + ResetApiKey: BaseCommandBody; + ResetQualityDefinitions: BaseCommandBody; +} + +export type CommandBodyForName = + CommandBodyMap[T]; + interface Command extends ModelBase { name: string; commandName: string; diff --git a/frontend/src/Commands/CommandNames.ts b/frontend/src/Commands/CommandNames.ts new file mode 100644 index 000000000..8cee7d38c --- /dev/null +++ b/frontend/src/Commands/CommandNames.ts @@ -0,0 +1,25 @@ +enum CommandNames { + ApplicationUpdate = 'ApplicationUpdate', + Backup = 'Backup', + ClearBlocklist = 'ClearBlocklist', + ClearLog = 'ClearLog', + CutoffUnmetEpisodeSearch = 'CutoffUnmetEpisodeSearch', + DeleteLogFiles = 'DeleteLogFiles', + DeleteUpdateLogFiles = 'DeleteUpdateLogFiles', + DownloadedEpisodesScan = 'DownloadedEpisodesScan', + EpisodeSearch = 'EpisodeSearch', + ManualImport = 'ManualImport', + MissingEpisodeSearch = 'MissingEpisodeSearch', + MoveSeries = 'MoveSeries', + RefreshMonitoredDownloads = 'RefreshMonitoredDownloads', + RefreshSeries = 'RefreshSeries', + RenameFiles = 'RenameFiles', + RenameSeries = 'RenameSeries', + ResetApiKey = 'ResetApiKey', + ResetQualityDefinitions = 'ResetQualityDefinitions', + RssSync = 'RssSync', + SeasonSearch = 'SeasonSearch', + SeriesSearch = 'SeriesSearch', +} + +export default CommandNames; diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js deleted file mode 100644 index 13ac9d62c..000000000 --- a/frontend/src/Commands/commandNames.js +++ /dev/null @@ -1,21 +0,0 @@ -export const APPLICATION_UPDATE = 'ApplicationUpdate'; -export const BACKUP = 'Backup'; -export const REFRESH_MONITORED_DOWNLOADS = 'RefreshMonitoredDownloads'; -export const CLEAR_BLOCKLIST = 'ClearBlocklist'; -export const CLEAR_LOGS = 'ClearLog'; -export const CUTOFF_UNMET_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch'; -export const DELETE_LOG_FILES = 'DeleteLogFiles'; -export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles'; -export const DOWNLOADED_EPISODES_SCAN = 'DownloadedEpisodesScan'; -export const EPISODE_SEARCH = 'EpisodeSearch'; -export const INTERACTIVE_IMPORT = 'ManualImport'; -export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch'; -export const MOVE_SERIES = 'MoveSeries'; -export const REFRESH_SERIES = 'RefreshSeries'; -export const RENAME_FILES = 'RenameFiles'; -export const RENAME_SERIES = 'RenameSeries'; -export const RESET_API_KEY = 'ResetApiKey'; -export const RESET_QUALITY_DEFINITIONS = 'ResetQualityDefinitions'; -export const RSS_SYNC = 'RssSync'; -export const SEASON_SEARCH = 'SeasonSearch'; -export const SERIES_SEARCH = 'SeriesSearch'; diff --git a/frontend/src/Commands/useCommands.ts b/frontend/src/Commands/useCommands.ts new file mode 100644 index 000000000..3489c9455 --- /dev/null +++ b/frontend/src/Commands/useCommands.ts @@ -0,0 +1,240 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo, useRef } from 'react'; +import { showMessage } from 'App/messagesStore'; +import Command, { CommandBody, NewCommandBody } from 'Commands/Command'; +import useApiMutation from 'Helpers/Hooks/useApiMutation'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; +import { + ERROR, + INFO, + type MessageType, + SUCCESS, +} from 'Helpers/Props/messageTypes'; +import { isSameCommand } from 'Utilities/Command'; + +const DEFAULT_COMMANDS: Command[] = []; +const COMMAND_REFETCH_INTERVAL = 5 * 60 * 1000; + +const commandFinishedCallbacks: Record void> = {}; + +export const useCommands = () => { + const result = useApiQuery({ + path: '/command', + queryOptions: { + refetchInterval: COMMAND_REFETCH_INTERVAL, + }, + }); + + return { + ...result, + data: result.data ?? DEFAULT_COMMANDS, + }; +}; + +export default useCommands; + +export const useExecuteCommand = () => { + const queryClient = useQueryClient(); + const lastCommandRef = useRef<{ + command: NewCommandBody; + timestamp: number; + } | null>(null); + + const { mutate } = useApiMutation({ + method: 'POST', + path: '/command', + mutationOptions: { + onSuccess: (newCommand: Command) => { + queryClient.setQueryData( + ['/command'], + (oldCommands = []) => { + return [...oldCommands, newCommand]; + } + ); + }, + }, + }); + + const executeCommand = useCallback( + (body: NewCommandBody, commandFinished?: (command: Command) => void) => { + const now = Date.now(); + const lastCommand = lastCommandRef.current; + + // Check if the same command was run within the last 5 seconds + if ( + lastCommand && + now - lastCommand.timestamp < 5000 && + isSameCommand(lastCommand.command, body) + ) { + console.warn( + 'Please wait at least 5 seconds before running this command again' + ); + return; + } + + // Update last command reference + lastCommandRef.current = { + command: body, + timestamp: now, + }; + + const executeWithCallback = (commandBody: NewCommandBody) => { + mutate(commandBody, { + onSuccess: (command) => { + if (commandFinished) { + commandFinishedCallbacks[command.id] = commandFinished; + } + }, + }); + }; + + executeWithCallback(body); + }, + [mutate] + ); + + return executeCommand; +}; + +export const useCancelCommand = (id: number) => { + const queryClient = useQueryClient(); + + const { mutate, isPending, error } = useApiMutation({ + method: 'DELETE', + path: `/command/${id}`, + mutationOptions: { + onSuccess: () => { + queryClient.setQueryData( + ['/command'], + (oldCommands = []) => { + return oldCommands.filter((command) => command.id !== id); + } + ); + }, + }, + }); + + return { + cancelCommand: mutate, + isCancellingCommand: isPending, + commandCancelError: error, + }; +}; + +export const useCommand = ( + commandName: string, + constraints: Partial = {} +) => { + const { data: commands } = useCommands(); + + return useMemo(() => { + return commands.find((command) => { + if (command.name !== commandName) { + return undefined; + } + + return (Object.keys(constraints) as Array).every( + (key) => { + const constraintValue = constraints[key]; + const commandValue = command.body?.[key]; + + if (constraintValue === undefined) { + return true; + } + + if (Array.isArray(constraintValue) && Array.isArray(commandValue)) { + return constraintValue.every((value) => + commandValue.includes(value) + ); + } + + return constraintValue === commandValue; + } + ); + }); + }, [commands, commandName, constraints]); +}; + +export const useCommandExecuting = ( + commandName: string, + constraints: Partial = {} +) => { + const command = useCommand(commandName, constraints); + + return command + ? command.status === 'queued' || command.status === 'started' + : false; +}; + +export const useExecutingCommands = () => { + const { data: commands } = useCommands(); + + return commands.filter( + (command) => command.status === 'queued' || command.status === 'started' + ); +}; + +export const useUpdateCommand = () => { + const queryClient = useQueryClient(); + + return (command: Command) => { + queryClient.setQueryData(['/command'], (oldCommands = []) => { + return oldCommands.map((existingCommand) => + existingCommand.id === command.id ? command : existingCommand + ); + }); + + // Show command message for user feedback + showCommandMessage(command); + + // Both successful and failed commands need to be + // completed, otherwise they spin until they time out. + const isFinished = + command.status === 'completed' || command.status === 'failed'; + + if (isFinished) { + const commandFinished = commandFinishedCallbacks[command.id]; + + if (commandFinished) { + commandFinished(command); + delete commandFinishedCallbacks[command.id]; + } + } + }; +}; + +function showCommandMessage(command: Command) { + const { + id, + name, + trigger, + message, + body = {} as CommandBody, + status, + } = command; + + const { sendUpdatesToClient, suppressMessages } = body; + + if (!message || !body || !sendUpdatesToClient || suppressMessages) { + return; + } + + let type: MessageType = INFO; + let hideAfter = 0; + + if (status === 'completed') { + type = SUCCESS; + hideAfter = 4; + } else if (status === 'failed') { + type = ERROR; + hideAfter = trigger === 'manual' ? 10 : 4; + } + + showMessage({ + id, + name, + message, + type, + hideAfter, + }); +} diff --git a/frontend/src/Components/SignalRListener.tsx b/frontend/src/Components/SignalRListener.tsx index 67b663cca..ac70fbc47 100644 --- a/frontend/src/Components/SignalRListener.tsx +++ b/frontend/src/Components/SignalRListener.tsx @@ -9,16 +9,12 @@ import { useDispatch } from 'react-redux'; import { setAppValue, setVersion } from 'App/appStore'; import ModelBase from 'App/ModelBase'; import Command from 'Commands/Command'; +import { useUpdateCommand } from 'Commands/useCommands'; import Episode from 'Episode/Episode'; import { EpisodeFile } from 'EpisodeFile/EpisodeFile'; import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery'; import Series from 'Series/Series'; import { removeItem, updateItem } from 'Store/Actions/baseActions'; -import { - fetchCommands, - finishCommand, - updateCommand, -} from 'Store/Actions/commandActions'; import { fetchQualityDefinitions } from 'Store/Actions/settingsActions'; import { repopulatePage } from 'Utilities/pagePopulator'; import SignalRLogger from 'Utilities/SignalRLogger'; @@ -37,6 +33,7 @@ interface SignalRMessage { function SignalRListener() { const queryClient = useQueryClient(); + const updateCommand = useUpdateCommand(); const dispatch = useDispatch(); const connection = useRef(null); @@ -79,7 +76,9 @@ function SignalRListener() { // Repopulate the page (if a repopulator is set) to ensure things // are in sync after reconnecting. queryClient.invalidateQueries({ queryKey: ['/series'] }); - dispatch(fetchCommands()); + + queryClient.invalidateQueries({ queryKey: ['/command'] }); + repopulatePage(); }); @@ -112,21 +111,13 @@ function SignalRListener() { if (name === 'command') { if (body.action === 'sync') { - dispatch(fetchCommands()); + queryClient.invalidateQueries({ queryKey: ['/command'] }); return; } const resource = body.resource as Command; - const status = resource.status; - // Both successful and failed commands need to be - // completed, otherwise they spin until they time out. - - if (status === 'completed' || status === 'failed') { - dispatch(finishCommand(resource)); - } else { - dispatch(updateCommand(resource)); - } + updateCommand(resource); return; } diff --git a/frontend/src/Episode/EpisodeSearchCell.tsx b/frontend/src/Episode/EpisodeSearchCell.tsx index fc40272cd..7e0f38a86 100644 --- a/frontend/src/Episode/EpisodeSearchCell.tsx +++ b/frontend/src/Episode/EpisodeSearchCell.tsx @@ -1,14 +1,12 @@ import React, { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { EPISODE_SEARCH } from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import { EpisodeEntity } from 'Episode/useEpisode'; import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; import { icons } from 'Helpers/Props'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; import translate from 'Utilities/String/translate'; import EpisodeDetailsModal from './EpisodeDetailsModal'; import styles from './EpisodeSearchCell.css'; @@ -28,25 +26,21 @@ function EpisodeSearchCell({ episodeTitle, showOpenSeriesButton, }: EpisodeSearchCellProps) { - const executingCommands = useSelector(createExecutingCommandsSelector()); - const isSearching = executingCommands.some(({ name, body }) => { - const { episodeIds = [] } = body; - return name === EPISODE_SEARCH && episodeIds.indexOf(episodeId) > -1; + const isSearching = useCommandExecuting(CommandNames.EpisodeSearch, { + episodeIds: [episodeId], }); - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const [isDetailsModalOpen, setDetailsModalOpen, setDetailsModalClosed] = useModalOpenState(false); const handleSearchPress = useCallback(() => { - dispatch( - executeCommand({ - name: EPISODE_SEARCH, - episodeIds: [episodeId], - }) - ); - }, [episodeId, dispatch]); + executeCommand({ + name: CommandNames.EpisodeSearch, + episodeIds: [episodeId], + }); + }, [episodeId, executeCommand]); return ( diff --git a/frontend/src/Episode/Search/EpisodeSearch.tsx b/frontend/src/Episode/Search/EpisodeSearch.tsx index 99c431133..1f70d24bc 100644 --- a/frontend/src/Episode/Search/EpisodeSearch.tsx +++ b/frontend/src/Episode/Search/EpisodeSearch.tsx @@ -1,12 +1,11 @@ import React, { useCallback, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; import { icons, kinds, sizes } from 'Helpers/Props'; import InteractiveSearch from 'InteractiveSearch/InteractiveSearch'; import useReleases from 'InteractiveSearch/useReleases'; -import { executeCommand } from 'Store/Actions/commandActions'; import translate from 'Utilities/String/translate'; import styles from './EpisodeSearch.css'; @@ -21,7 +20,7 @@ function EpisodeSearch({ startInteractiveSearch, onModalClose, }: EpisodeSearchProps) { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { isFetched } = useReleases({ episodeId }); const [isInteractiveSearchOpen, setIsInteractiveSearchOpen] = useState( @@ -29,15 +28,13 @@ function EpisodeSearch({ ); const handleQuickSearchPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.EPISODE_SEARCH, - episodeIds: [episodeId], - }) - ); + executeCommand({ + name: CommandNames.EpisodeSearch, + episodeIds: [episodeId], + }); onModalClose(); - }, [episodeId, dispatch, onModalClose]); + }, [episodeId, executeCommand, onModalClose]); const handleInteractiveSearchPress = useCallback(() => { setIsInteractiveSearchOpen(true); diff --git a/frontend/src/Helpers/Hooks/useAppPage.ts b/frontend/src/Helpers/Hooks/useAppPage.ts index 75797b9ee..d804c4037 100644 --- a/frontend/src/Helpers/Hooks/useAppPage.ts +++ b/frontend/src/Helpers/Hooks/useAppPage.ts @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import { useTranslations } from 'App/useTranslations'; +import useCommands from 'Commands/useCommands'; import useCustomFilters from 'Filters/useCustomFilters'; import useSeries from 'Series/useSeries'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; @@ -77,6 +78,8 @@ const createErrorsSelector = ({ const useAppPage = () => { const dispatch = useDispatch(); + useCommands(); + const { isFetched: isCustomFiltersFetched, error: customFiltersError } = useCustomFilters(); diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx index 4e17acece..c723a6e43 100644 --- a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.tsx @@ -2,7 +2,8 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; import PathInput from 'Components/Form/PathInput'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; @@ -14,7 +15,6 @@ 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 { executeCommand } from 'Store/Actions/commandActions'; import { addRecentFolder } from 'Store/Actions/interactiveImportActions'; import translate from 'Utilities/String/translate'; import FavoriteFolderRow from './FavoriteFolderRow'; @@ -64,6 +64,8 @@ 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, @@ -97,15 +99,13 @@ function InteractiveImportSelectFolderModalContent( const onQuickImportPress = useCallback(() => { dispatch(addRecentFolder({ folder })); - dispatch( - executeCommand({ - name: commandNames.DOWNLOADED_EPISODES_SCAN, - path: folder, - }) - ); + executeCommand({ + name: CommandNames.DownloadedEpisodesScan, + path: folder, + }); onModalClose(); - }, [folder, onModalClose, dispatch]); + }, [folder, onModalClose, dispatch, executeCommand]); const onInteractiveImportPress = useCallback(() => { dispatch(addRecentFolder({ folder })); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx index 6be41d450..eefe0f08f 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx @@ -5,7 +5,8 @@ import { createSelector } from 'reselect'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; import AppState from 'App/State/AppState'; import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; import SelectInput, { SelectInputOption } from 'Components/Form/SelectInput'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; @@ -46,7 +47,6 @@ import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; import Language from 'Language/Language'; import { QualityModel } from 'Quality/Quality'; import Series from 'Series/Series'; -import { executeCommand } from 'Store/Actions/commandActions'; import { clearInteractiveImport, fetchInteractiveImportItems, @@ -280,6 +280,7 @@ function InteractiveImportModalContentInner( useState(null); const previousIsDeleting = usePrevious(isDeleting); const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { allSelected, @@ -601,13 +602,11 @@ function InteractiveImportModalContentInner( } if (files.length) { - dispatch( - executeCommand({ - name: commandNames.INTERACTIVE_IMPORT, - files, - importMode: finalImportMode, - }) - ); + executeCommand({ + name: CommandNames.ManualImport, + files, + importMode: finalImportMode, + }); shouldClose = true; } @@ -623,7 +622,7 @@ function InteractiveImportModalContentInner( originalItems, selectedIds, onModalClose, - dispatch, + executeCommand, updateEpisodeFiles, ]); diff --git a/frontend/src/Organize/OrganizePreviewModalContent.tsx b/frontend/src/Organize/OrganizePreviewModalContent.tsx index 9916832f2..c0019197f 100644 --- a/frontend/src/Organize/OrganizePreviewModalContent.tsx +++ b/frontend/src/Organize/OrganizePreviewModalContent.tsx @@ -3,7 +3,8 @@ import { useDispatch, useSelector } from 'react-redux'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; import AppState from 'App/State/AppState'; import { OrganizePreviewModel } from 'App/State/OrganizePreviewAppState'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import CheckInput from 'Components/Form/CheckInput'; import Button from 'Components/Link/Button'; @@ -16,7 +17,6 @@ import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; import formatSeason from 'Season/formatSeason'; import { useSingleSeries } from 'Series/useSeries'; -import { executeCommand } from 'Store/Actions/commandActions'; import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions'; import { fetchNamingSettings } from 'Store/Actions/settingsActions'; import { CheckInputChanged } from 'typings/inputs'; @@ -46,6 +46,7 @@ function OrganizePreviewModalContentInner({ onModalClose, }: OrganizePreviewModalContentProps) { const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { items, isFetching: isPreviewFetching, @@ -87,16 +88,14 @@ function OrganizePreviewModalContentInner({ const handleOrganizePress = useCallback(() => { const files = getSelectedIds(); - dispatch( - executeCommand({ - name: commandNames.RENAME_FILES, - files, - seriesId, - }) - ); + executeCommand({ + name: CommandNames.RenameFiles, + files, + seriesId, + }); onModalClose(); - }, [seriesId, getSelectedIds, dispatch, onModalClose]); + }, [seriesId, getSelectedIds, executeCommand, onModalClose]); useEffect(() => { dispatch(fetchOrganizePreview({ seriesId, seasonNumber })); diff --git a/frontend/src/Series/Details/SeriesDetails.tsx b/frontend/src/Series/Details/SeriesDetails.tsx index 89cbcb6c8..ac484b330 100644 --- a/frontend/src/Series/Details/SeriesDetails.tsx +++ b/frontend/src/Series/Details/SeriesDetails.tsx @@ -1,7 +1,7 @@ import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommands, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import HeartRating from 'Components/HeartRating'; import Icon from 'Components/Icon'; @@ -44,8 +44,6 @@ import useSeries, { useToggleSeriesMonitored, } from 'Series/useSeries'; import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import sortByProp from 'Utilities/Array/sortByProp'; import { findCommand, isCommandExecuting } from 'Utilities/Command'; import formatBytes from 'Utilities/Number/formatBytes'; @@ -85,7 +83,7 @@ interface SeriesDetailsProps { } function SeriesDetails({ seriesId }: SeriesDetailsProps) { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const series = useSingleSeries(seriesId); const { toggleSeriesMonitored, isTogglingSeriesMonitored } = @@ -114,11 +112,11 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { hasEpisodeFiles, } = useEpisodeFiles({ seriesId }); - const commands = useSelector(createCommandsSelector()); + const { data: commands } = useCommands(); const { isRefreshing, isRenaming, isSearching } = useMemo(() => { const seriesRefreshingCommand = findCommand(commands, { - name: commandNames.REFRESH_SERIES, + name: CommandNames.RefreshSeries, }); const isSeriesRefreshingCommandExecuting = isCommandExecuting( @@ -127,33 +125,39 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { const allSeriesRefreshing = isSeriesRefreshingCommandExecuting && - !seriesRefreshingCommand?.body.seriesIds?.length; + seriesRefreshingCommand && + (!('seriesIds' in seriesRefreshingCommand.body) || + seriesRefreshingCommand.body.seriesIds.length === 0); const isSeriesRefreshing = isSeriesRefreshingCommandExecuting && - seriesRefreshingCommand?.body.seriesIds?.includes(seriesId); + seriesRefreshingCommand && + 'seriesIds' in seriesRefreshingCommand.body && + seriesRefreshingCommand.body.seriesIds.includes(seriesId); const isSearchingExecuting = isCommandExecuting( findCommand(commands, { - name: commandNames.SERIES_SEARCH, + name: CommandNames.SeriesSearch, seriesId, }) ); const isRenamingFiles = isCommandExecuting( findCommand(commands, { - name: commandNames.RENAME_FILES, + name: CommandNames.RenameFiles, seriesId, }) ); const isRenamingSeriesCommand = findCommand(commands, { - name: commandNames.RENAME_SERIES, + name: CommandNames.RenameSeries, }); const isRenamingSeries = isCommandExecuting(isRenamingSeriesCommand) && - isRenamingSeriesCommand?.body?.seriesIds?.includes(seriesId); + isRenamingSeriesCommand && + 'seriesIds' in isRenamingSeriesCommand.body && + isRenamingSeriesCommand.body.seriesIds.includes(seriesId); return { isRefreshing: isSeriesRefreshing || allSeriesRefreshing, @@ -325,22 +329,18 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { ); const handleRefreshPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.REFRESH_SERIES, - seriesId, - }) - ); - }, [seriesId, dispatch]); + executeCommand({ + name: CommandNames.RefreshSeries, + seriesId, + }); + }, [seriesId, executeCommand]); const handleSearchPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.SERIES_SEARCH, - seriesId, - }) - ); - }, [seriesId, dispatch]); + executeCommand({ + name: CommandNames.SeriesSearch, + seriesId, + }); + }, [seriesId, executeCommand]); const populate = useCallback(() => { refetchEpisodes(); @@ -356,7 +356,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) { return () => { unregisterPagePopulator(populate); }; - }, [populate, dispatch]); + }, [populate]); useEffect(() => { if ((!isRefreshing && wasRefreshing) || (!isRenaming && wasRenaming)) { diff --git a/frontend/src/Series/Details/SeriesDetailsSeason.tsx b/frontend/src/Series/Details/SeriesDetailsSeason.tsx index 88d23ebc3..d48c54b03 100644 --- a/frontend/src/Series/Details/SeriesDetailsSeason.tsx +++ b/frontend/src/Series/Details/SeriesDetailsSeason.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; +import { useDispatch } from 'react-redux'; import { useAppDimension } from 'App/appStore'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommands } from 'Commands/useCommands'; import Icon from 'Components/Icon'; import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; @@ -34,7 +34,6 @@ import SeriesHistoryModal from 'Series/History/SeriesHistoryModal'; import SeasonInteractiveSearchModal from 'Series/Search/SeasonInteractiveSearchModal'; import { Statistics } from 'Series/Series'; import { useSingleSeries, useToggleSeasonMonitored } from 'Series/useSeries'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import { TableOptionsChangePayload } from 'typings/Table'; import { findCommand, isCommandExecuting } from 'Utilities/Command'; import isAfter from 'Utilities/Date/isAfter'; @@ -85,16 +84,15 @@ function getSeasonStatistics(episodes: Episode[]) { }; } -function createIsSearchingSelector(seriesId: number, seasonNumber: number) { - return createSelector(createCommandsSelector(), (commands) => { - return isCommandExecuting( - findCommand(commands, { - name: commandNames.SEASON_SEARCH, - seriesId, - seasonNumber, - }) - ); - }); +function useIsSearching(seriesId: number, seasonNumber: number) { + const { data: commands } = useCommands(); + return isCommandExecuting( + findCommand(commands, { + name: CommandNames.SeasonSearch, + seriesId, + seasonNumber, + }) + ); } interface SeriesDetailsSeasonProps { @@ -121,9 +119,7 @@ function SeriesDetailsSeason({ const { columns, sortKey, sortDirection } = useEpisodeOptions(); const isSmallScreen = useAppDimension('isSmallScreen'); - const isSearching = useSelector( - createIsSearchingSelector(seriesId, seasonNumber) - ); + const isSearching = useIsSearching(seriesId, seasonNumber); const { sizeOnDisk = 0 } = statistics; @@ -199,7 +195,7 @@ function SeriesDetailsSeason({ const handleSearchPress = useCallback(() => { dispatch({ - name: commandNames.SEASON_SEARCH, + name: CommandNames.SeasonSearch, seriesId, seasonNumber, }); diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx b/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx index d7e929a59..61ed6c44c 100644 --- a/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.tsx @@ -1,8 +1,8 @@ import classNames from 'classnames'; import React, { useCallback, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; import TextTruncate from 'react-text-truncate'; -import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; @@ -15,7 +15,6 @@ import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect import { Statistics } from 'Series/Series'; import { useSeriesOverviewOptions } from 'Series/seriesOptionsStore'; import SeriesPoster from 'Series/SeriesPoster'; -import { executeCommand } from 'Store/Actions/commandActions'; import dimensions from 'Styles/Variables/dimensions'; import fonts from 'Styles/Variables/fonts'; import translate from 'Utilities/String/translate'; @@ -60,6 +59,53 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) { const overviewOptions = useSeriesOverviewOptions(); + const executeCommand = useExecuteCommand(); + const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false); + const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + executeCommand({ + name: CommandNames.RefreshSeries, + seriesIds: [seriesId], + }); + }, [seriesId, executeCommand]); + + const onSearchPress = useCallback(() => { + executeCommand({ + name: CommandNames.SeriesSearch, + seriesId, + }); + }, [seriesId, executeCommand]); + + const onEditSeriesPress = useCallback(() => { + setIsEditSeriesModalOpen(true); + }, [setIsEditSeriesModalOpen]); + + const onEditSeriesModalClose = useCallback(() => { + setIsEditSeriesModalOpen(false); + }, [setIsEditSeriesModalOpen]); + + const onDeleteSeriesPress = useCallback(() => { + setIsEditSeriesModalOpen(false); + setIsDeleteSeriesModalOpen(true); + }, [setIsDeleteSeriesModalOpen]); + + const onDeleteSeriesModalClose = useCallback(() => { + setIsDeleteSeriesModalOpen(false); + }, [setIsDeleteSeriesModalOpen]); + + const contentHeight = useMemo(() => { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; + }, [rowHeight, isSmallScreen]); + + const overviewHeight = contentHeight - TITLE_HEIGHT; + + if (!series) { + return null; + } + const { title, monitored, @@ -84,45 +130,6 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) { sizeOnDisk = 0, } = statistics; - const dispatch = useDispatch(); - const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false); - const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false); - - const onRefreshPress = useCallback(() => { - dispatch( - executeCommand({ - name: REFRESH_SERIES, - seriesIds: [seriesId], - }) - ); - }, [seriesId, dispatch]); - - const onSearchPress = useCallback(() => { - dispatch( - executeCommand({ - name: SERIES_SEARCH, - seriesId, - }) - ); - }, [seriesId, dispatch]); - - const onEditSeriesPress = useCallback(() => { - setIsEditSeriesModalOpen(true); - }, [setIsEditSeriesModalOpen]); - - const onEditSeriesModalClose = useCallback(() => { - setIsEditSeriesModalOpen(false); - }, [setIsEditSeriesModalOpen]); - - const onDeleteSeriesPress = useCallback(() => { - setIsEditSeriesModalOpen(false); - setIsDeleteSeriesModalOpen(true); - }, [setIsDeleteSeriesModalOpen]); - - const onDeleteSeriesModalClose = useCallback(() => { - setIsDeleteSeriesModalOpen(false); - }, [setIsDeleteSeriesModalOpen]); - const link = `/series/${titleSlug}`; const elementStyle = { @@ -130,14 +137,6 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) { height: `${posterHeight}px`, }; - const contentHeight = useMemo(() => { - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - - return rowHeight - padding; - }, [rowHeight, isSmallScreen]); - - const overviewHeight = contentHeight - TITLE_HEIGHT; - return (
diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx b/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx index a9c03f009..048c350ca 100644 --- a/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx +++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.tsx @@ -1,7 +1,8 @@ import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames'; +import { useSelector } from 'react-redux'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; @@ -15,7 +16,6 @@ import SeriesIndexPosterSelect from 'Series/Index/Select/SeriesIndexPosterSelect import { Statistics } from 'Series/Series'; import { useSeriesPosterOptions } from 'Series/seriesOptionsStore'; import SeriesPoster from 'Series/SeriesPoster'; -import { executeCommand } from 'Store/Actions/commandActions'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import formatDateTime from 'Utilities/Date/formatDateTime'; import getRelativeDate from 'Utilities/Date/getRelativeDate'; @@ -50,52 +50,24 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) { const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = useSelector(createUISettingsSelector()); - const { - title, - monitored, - status, - path, - titleSlug, - originalLanguage, - network, - nextAiring, - previousAiring, - added, - statistics = {} as Statistics, - images, - tags, - } = series; - - const { - seasonCount = 0, - episodeCount = 0, - episodeFileCount = 0, - totalEpisodeCount = 0, - sizeOnDisk = 0, - } = statistics; - - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const [hasPosterError, setHasPosterError] = useState(false); const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false); const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false); const onRefreshPress = useCallback(() => { - dispatch( - executeCommand({ - name: REFRESH_SERIES, - seriesIds: [seriesId], - }) - ); - }, [seriesId, dispatch]); + executeCommand({ + name: CommandNames.RefreshSeries, + seriesIds: [seriesId], + }); + }, [seriesId, executeCommand]); const onSearchPress = useCallback(() => { - dispatch( - executeCommand({ - name: SERIES_SEARCH, - seriesId, - }) - ); - }, [seriesId, dispatch]); + executeCommand({ + name: CommandNames.SeriesSearch, + seriesId, + }); + }, [seriesId, executeCommand]); const onPosterLoadError = useCallback(() => { setHasPosterError(true); @@ -122,6 +94,34 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) { setIsDeleteSeriesModalOpen(false); }, [setIsDeleteSeriesModalOpen]); + if (!series) { + return null; + } + + const { + title, + monitored, + status, + path, + titleSlug, + originalLanguage, + network, + nextAiring, + previousAiring, + added, + statistics = {} as Statistics, + images, + tags, + } = series; + + const { + seasonCount = 0, + episodeCount = 0, + episodeFileCount = 0, + totalEpisodeCount = 0, + sizeOnDisk = 0, + } = statistics; + const link = `/series/${titleSlug}`; const elementStyle = { diff --git a/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.tsx b/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.tsx index ecdfbaad3..57b413851 100644 --- a/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.tsx +++ b/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.tsx @@ -1,8 +1,8 @@ import { orderBy } from 'lodash'; import React, { useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; import { useSelect } from 'App/Select/SelectContext'; -import { RENAME_SERIES } from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; @@ -13,7 +13,6 @@ import ModalHeader from 'Components/Modal/ModalHeader'; import { icons, kinds } from 'Helpers/Props'; import Series from 'Series/Series'; import useSeries from 'Series/useSeries'; -import { executeCommand } from 'Store/Actions/commandActions'; import translate from 'Utilities/String/translate'; import styles from './OrganizeSeriesModalContent.css'; @@ -25,7 +24,7 @@ function OrganizeSeriesModalContent({ onModalClose, }: OrganizeSeriesModalContentProps) { const { data: allSeries } = useSeries(); - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { useSelectedIds } = useSelect(); const seriesIds = useSelectedIds(); @@ -46,15 +45,13 @@ function OrganizeSeriesModalContent({ }, [allSeries, seriesIds]); const onOrganizePress = useCallback(() => { - dispatch( - executeCommand({ - name: RENAME_SERIES, - seriesIds, - }) - ); + executeCommand({ + name: CommandNames.RenameSeries, + seriesIds, + }); onModalClose(); - }, [seriesIds, onModalClose, dispatch]); + }, [seriesIds, onModalClose, executeCommand]); return ( diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx index 63d9bb253..0d3cc20f2 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; import { useSelect } from 'App/Select/SelectContext'; -import { RENAME_SERIES } from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting } from 'Commands/useCommands'; import SpinnerButton from 'Components/Link/SpinnerButton'; import PageContentFooter from 'Components/Page/PageContentFooter'; import usePrevious from 'Helpers/Hooks/usePrevious'; @@ -12,7 +12,6 @@ import { useSaveSeriesEditor, useUpdateSeriesMonitor, } from 'Series/useSeries'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import translate from 'Utilities/String/translate'; import DeleteSeriesModal from './Delete/DeleteSeriesModal'; import EditSeriesModal from './Edit/EditSeriesModal'; @@ -36,9 +35,7 @@ function SeriesIndexSelectFooter() { useUpdateSeriesMonitor(); const { isBulkDeleting, bulkDeleteError } = useBulkDeleteSeries(); - const isOrganizingSeries = useSelector( - createCommandExecutingSelector(RENAME_SERIES) - ); + const isOrganizingSeries = useCommandExecuting(CommandNames.RenameSeries); const isSaving = isSavingSeriesEditor || isUpdatingSeriesMonitor; const isDeleting = isBulkDeleting; diff --git a/frontend/src/Series/Index/SeriesIndex.tsx b/frontend/src/Series/Index/SeriesIndex.tsx index c1267fc62..2010cf745 100644 --- a/frontend/src/Series/Index/SeriesIndex.tsx +++ b/frontend/src/Series/Index/SeriesIndex.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider'; import { useAppDimension } from 'App/appStore'; import { SelectProvider } from 'App/Select/SelectContext'; -import { RSS_SYNC } from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; @@ -27,9 +27,7 @@ import { useSeriesOptions, } from 'Series/seriesOptionsStore'; import { FILTERS, useSeriesIndex } from 'Series/useSeries'; -import { executeCommand } from 'Store/Actions/commandActions'; import scrollPositions from 'Store/scrollPositions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import translate from 'Utilities/String/translate'; import SeriesIndexFilterMenu from './Menus/SeriesIndexFilterMenu'; import SeriesIndexSortMenu from './Menus/SeriesIndexSortMenu'; @@ -80,11 +78,9 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { const customFilters = useCustomFiltersList('series'); - const isRssSyncExecuting = useSelector( - createCommandExecutingSelector(RSS_SYNC) - ); + const executeCommand = useExecuteCommand(); + const isRssSyncExecuting = useCommandExecuting(CommandNames.RssSync); const isSmallScreen = useAppDimension('isSmallScreen'); - const dispatch = useDispatch(); const scrollerRef = useRef(null); const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); const [jumpToCharacter, setJumpToCharacter] = useState( @@ -93,12 +89,10 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { const [isSelectMode, setIsSelectMode] = useState(false); const onRssSyncPress = useCallback(() => { - dispatch( - executeCommand({ - name: RSS_SYNC, - }) - ); - }, [dispatch]); + executeCommand({ + name: CommandNames.RssSync, + }); + }, [executeCommand]); const onSelectModePress = useCallback(() => { setIsSelectMode(!isSelectMode); diff --git a/frontend/src/Series/Index/SeriesIndexRefreshSeriesButton.tsx b/frontend/src/Series/Index/SeriesIndexRefreshSeriesButton.tsx index 3d454f0ce..3ecd1fba2 100644 --- a/frontend/src/Series/Index/SeriesIndexRefreshSeriesButton.tsx +++ b/frontend/src/Series/Index/SeriesIndexRefreshSeriesButton.tsx @@ -1,12 +1,11 @@ import React, { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { useSelect } from 'App/Select/SelectContext'; -import { REFRESH_SERIES } from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import { icons } from 'Helpers/Props'; +import Series from 'Series/Series'; import { useSeriesIndex } from 'Series/useSeries'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import translate from 'Utilities/String/translate'; interface SeriesIndexRefreshSeriesButtonProps { @@ -17,14 +16,12 @@ interface SeriesIndexRefreshSeriesButtonProps { function SeriesIndexRefreshSeriesButton( props: SeriesIndexRefreshSeriesButtonProps ) { - const isRefreshing = useSelector( - createCommandExecutingSelector(REFRESH_SERIES) - ); + const isRefreshing = useCommandExecuting(CommandNames.RefreshSeries); const { data, totalItems } = useSeriesIndex(); - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { isSelectMode, selectedFilterKey } = props; - const { anySelected, getSelectedIds } = useSelect(); + const { anySelected, getSelectedIds } = useSelect(); let refreshLabel = translate('UpdateAll'); @@ -38,13 +35,11 @@ function SeriesIndexRefreshSeriesButton( const seriesToRefresh = isSelectMode && anySelected ? getSelectedIds() : data.map((m) => m.id); - dispatch( - executeCommand({ - name: REFRESH_SERIES, - seriesIds: seriesToRefresh, - }) - ); - }, [dispatch, anySelected, isSelectMode, data, getSelectedIds]); + executeCommand({ + name: CommandNames.RefreshSeries, + seriesIds: seriesToRefresh, + }); + }, [executeCommand, anySelected, isSelectMode, data, getSelectedIds]); return ( { - dispatch( - executeCommand({ - name: REFRESH_SERIES, - seriesIds: [seriesId], - }) - ); - }, [seriesId, dispatch]); + executeCommand({ + name: CommandNames.RefreshSeries, + seriesIds: [seriesId], + }); + }, [seriesId, executeCommand]); const onSearchPress = useCallback(() => { - dispatch( - executeCommand({ - name: SERIES_SEARCH, - seriesId, - }) - ); - }, [seriesId, dispatch]); + executeCommand({ + name: CommandNames.SeriesSearch, + seriesId, + }); + }, [seriesId, executeCommand]); const onBannerLoadError = useCallback(() => { setHasBannerError(true); @@ -150,6 +111,44 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { [toggleSelected] ); + if (!series) { + return null; + } + + const { + title, + monitored, + monitorNewItems, + status, + path, + titleSlug, + nextAiring, + previousAiring, + added, + statistics = {} as Statistics, + seasonFolder, + images, + seriesType, + network, + originalLanguage, + certification, + year, + useSceneNumbering, + genres = [], + ratings, + seasons = [], + tags = [], + } = series; + + const { + seasonCount = 0, + episodeCount = 0, + episodeFileCount = 0, + totalEpisodeCount = 0, + sizeOnDisk = 0, + releaseGroups = [], + } = statistics; + return ( <> {isSelectMode ? ( diff --git a/frontend/src/Series/Index/useSeriesIndexItem.ts b/frontend/src/Series/Index/useSeriesIndexItem.ts index 30c919fc8..3a3ac1602 100644 --- a/frontend/src/Series/Index/useSeriesIndexItem.ts +++ b/frontend/src/Series/Index/useSeriesIndexItem.ts @@ -1,49 +1,37 @@ import { maxBy } from 'lodash'; -import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import Command from 'Commands/Command'; -import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting } from 'Commands/useCommands'; +import { Season } from 'Series/Series'; import { useSingleSeries } from 'Series/useSeries'; -import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; import createSeriesQualityProfileSelector from 'Store/Selectors/createSeriesQualityProfileSelector'; -function useSeriesIndexItem(seriesId: number) { +export function useSeriesIndexItem(seriesId: number) { const series = useSingleSeries(seriesId); const qualityProfile = useSelector( createSeriesQualityProfileSelector(series) ); - const executingCommands: Command[] = useSelector( - createExecutingCommandsSelector() + + const isRefreshingSeries = useCommandExecuting(CommandNames.RefreshSeries, { + seriesIds: [seriesId], + }); + + const isSearchingSeries = useCommandExecuting(CommandNames.SeriesSearch, { + seriesId, + }); + + const latestSeason: Season | undefined = maxBy( + series?.seasons || [], + (season) => season.seasonNumber ); - return useMemo(() => { - if (!series) { - throw new Error('Series not found'); - } - - const isRefreshingSeries = executingCommands.some((command) => { - return ( - command.name === REFRESH_SERIES && - command.body.seriesIds?.includes(series.id) - ); - }); - - const isSearchingSeries = executingCommands.some((command) => { - return ( - command.name === SERIES_SEARCH && command.body.seriesId === seriesId - ); - }); - - const latestSeason = maxBy(series.seasons, (season) => season.seasonNumber); - - return { - series, - qualityProfile, - latestSeason, - isRefreshingSeries, - isSearchingSeries, - }; - }, [series, qualityProfile, executingCommands, seriesId]); + return { + series, + qualityProfile, + latestSeason, + isRefreshingSeries, + isSearchingSeries, + }; } export default useSeriesIndexItem; diff --git a/frontend/src/Settings/General/GeneralSettings.tsx b/frontend/src/Settings/General/GeneralSettings.tsx index 8bb29cf18..8f8d6baae 100644 --- a/frontend/src/Settings/General/GeneralSettings.tsx +++ b/frontend/src/Settings/General/GeneralSettings.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import Form from 'Components/Form/Form'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -16,7 +17,6 @@ import { saveGeneralSettings, setGeneralSettingsValue, } from 'Store/Actions/settingsActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; import { useIsWindowsService } from 'System/Status/useSystemStatus'; import { useRestart } from 'System/useSystem'; @@ -47,9 +47,7 @@ function GeneralSettings() { const dispatch = useDispatch(); const isWindowsService = useIsWindowsService(); const { mutate: restart } = useRestart(); - const isResettingApiKey = useSelector( - createCommandExecutingSelector(commandNames.RESET_API_KEY) - ); + const isResettingApiKey = useCommandExecuting(CommandNames.ResetApiKey); const { isFetching, diff --git a/frontend/src/Settings/General/SecuritySettings.tsx b/frontend/src/Settings/General/SecuritySettings.tsx index e32178254..0f339e0a3 100644 --- a/frontend/src/Settings/General/SecuritySettings.tsx +++ b/frontend/src/Settings/General/SecuritySettings.tsx @@ -1,6 +1,6 @@ import React, { FocusEvent, useCallback, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useExecuteCommand } from 'Commands/useCommands'; import FieldSet from 'Components/FieldSet'; import FormGroup from 'Components/Form/FormGroup'; import FormInputButton from 'Components/Form/FormInputButton'; @@ -11,7 +11,6 @@ import Icon from 'Components/Icon'; import ClipboardButton from 'Components/Link/ClipboardButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import { icons, inputTypes, kinds } from 'Helpers/Props'; -import { executeCommand } from 'Store/Actions/commandActions'; import { InputChanged } from 'typings/inputs'; import { PendingSection } from 'typings/pending'; import General from 'typings/Settings/General'; @@ -108,7 +107,7 @@ function SecuritySettings({ isResettingApiKey, onInputChange, }: SecuritySettingsProps) { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const [isConfirmApiKeyResetModalOpen, setIsConfirmApiKeyResetModalOpen] = useState(false); @@ -127,14 +126,14 @@ function SecuritySettings({ const handleConfirmResetApiKey = useCallback(() => { setIsConfirmApiKeyResetModalOpen(false); - dispatch(executeCommand({ name: commandNames.RESET_API_KEY })); - }, [dispatch]); + executeCommand({ name: CommandNames.ResetApiKey }); + }, [executeCommand]); const handleCloseResetApiKeyModal = useCallback(() => { setIsConfirmApiKeyResetModalOpen(false); }, []); - // createCommandExecutingSelector(commandNames.RESET_API_KEY), + // createCommandExecutingSelector(CommandNames.RESET_API_KEY), const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; diff --git a/frontend/src/Settings/Quality/Quality.tsx b/frontend/src/Settings/Quality/Quality.tsx index f0ce86f70..cf90d8dea 100644 --- a/frontend/src/Settings/Quality/Quality.tsx +++ b/frontend/src/Settings/Quality/Quality.tsx @@ -1,13 +1,12 @@ import React, { useCallback, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting } from 'Commands/useCommands'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import { icons } from 'Helpers/Props'; import SettingsToolbar from 'Settings/SettingsToolbar'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { SaveCallback, SettingsStateChange, @@ -17,8 +16,8 @@ import QualityDefinitions from './Definition/QualityDefinitions'; import ResetQualityDefinitionsModal from './Reset/ResetQualityDefinitionsModal'; function Quality() { - const isResettingQualityDefinitions = useSelector( - createCommandExecutingSelector(commandNames.RESET_QUALITY_DEFINITIONS) + const isResettingQualityDefinitions = useCommandExecuting( + CommandNames.ResetQualityDefinitions ); const saveDefinitions = useRef<() => void>(); diff --git a/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.tsx b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.tsx index bb007f989..50ddc20e5 100644 --- a/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.tsx +++ b/frontend/src/Settings/Quality/Reset/ResetQualityDefinitionsModalContent.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; @@ -10,8 +10,6 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { InputChanged } from 'typings/inputs'; import translate from 'Utilities/String/translate'; import styles from './ResetQualityDefinitionsModalContent.css'; @@ -23,10 +21,9 @@ interface ResetQualityDefinitionsModalContentProps { function ResetQualityDefinitionsModalContent({ onModalClose, }: ResetQualityDefinitionsModalContentProps) { - const dispatch = useDispatch(); - - const isResettingQualityDefinitions = useSelector( - createCommandExecutingSelector(commandNames.RESET_QUALITY_DEFINITIONS) + const executeCommand = useExecuteCommand(); + const isResettingQualityDefinitions = useCommandExecuting( + CommandNames.ResetQualityDefinitions ); const [resetDefinitionTitles, setResetDefinitionTitles] = useState(false); @@ -43,14 +40,12 @@ function ResetQualityDefinitionsModalContent({ setResetDefinitionTitles(false); - dispatch( - executeCommand({ - name: commandNames.RESET_QUALITY_DEFINITIONS, - resetTitles, - }) - ); + executeCommand({ + name: CommandNames.ResetQualityDefinitions, + resetTitles, + }); onModalClose(); - }, [resetDefinitionTitles, dispatch, onModalClose]); + }, [resetDefinitionTitles, executeCommand, onModalClose]); return ( diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js deleted file mode 100644 index efdd99d93..000000000 --- a/frontend/src/Store/Actions/commandActions.js +++ /dev/null @@ -1,219 +0,0 @@ -import { batchActions } from 'redux-batched-actions'; -import { hideMessage, showMessage } from 'App/messagesStore'; -import { messageTypes } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; -import { isSameCommand } from 'Utilities/Command'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import { removeItem, updateItem } from './baseActions'; -import createFetchHandler from './Creators/createFetchHandler'; -import createHandleActions from './Creators/createHandleActions'; -import createRemoveItemHandler from './Creators/createRemoveItemHandler'; - -// -// Variables - -export const section = 'commands'; - -let lastCommand = null; -let lastCommandTimeout = null; -const removeCommandTimeoutIds = {}; -const commandFinishedCallbacks = {}; - -// -// State - -export const defaultState = { - isFetching: false, - isPopulated: false, - error: null, - items: [], - handlers: {} -}; - -// -// Actions Types - -export const FETCH_COMMANDS = 'commands/fetchCommands'; -export const EXECUTE_COMMAND = 'commands/executeCommand'; -export const CANCEL_COMMAND = 'commands/cancelCommand'; -export const ADD_COMMAND = 'commands/addCommand'; -export const UPDATE_COMMAND = 'commands/updateCommand'; -export const FINISH_COMMAND = 'commands/finishCommand'; -export const REMOVE_COMMAND = 'commands/removeCommand'; - -// -// Action Creators - -export const fetchCommands = createThunk(FETCH_COMMANDS); -export const executeCommand = createThunk(EXECUTE_COMMAND); -export const cancelCommand = createThunk(CANCEL_COMMAND); -export const addCommand = createThunk(ADD_COMMAND); -export const updateCommand = createThunk(UPDATE_COMMAND); -export const finishCommand = createThunk(FINISH_COMMAND); -export const removeCommand = createThunk(REMOVE_COMMAND); - -// -// Helpers - -function showCommandMessage(payload, dispatch) { - const { - id, - name, - trigger, - message, - body = {}, - status - } = payload; - - const { - sendUpdatesToClient, - suppressMessages - } = body; - - if (!message || !body || !sendUpdatesToClient || suppressMessages) { - return; - } - - let type = messageTypes.INFO; - let hideAfter = 0; - - if (status === 'completed') { - type = messageTypes.SUCCESS; - hideAfter = 4; - } else if (status === 'failed') { - type = messageTypes.ERROR; - hideAfter = trigger === 'manual' ? 10 : 4; - } - - showMessage({ - id, - name, - message, - type, - hideAfter - }); -} - -function scheduleRemoveCommand(command, dispatch) { - const { - id, - status - } = command; - - if (status === 'queued') { - return; - } - - const timeoutId = removeCommandTimeoutIds[id]; - - if (timeoutId) { - clearTimeout(timeoutId); - } - - removeCommandTimeoutIds[id] = setTimeout(() => { - dispatch(batchActions([ - removeCommand({ section: 'commands', id }) - ])); - - hideMessage({ id }); - - delete removeCommandTimeoutIds[id]; - }, 60000 * 5); -} - -export function executeCommandHelper(payload, dispatch) { - // TODO: show a message for the user - if (lastCommand && isSameCommand(lastCommand, payload)) { - console.warn('Please wait at least 5 seconds before running this command again'); - } - - lastCommand = payload; - - // clear last command after 5 seconds. - if (lastCommandTimeout) { - clearTimeout(lastCommandTimeout); - } - - lastCommandTimeout = setTimeout(() => { - lastCommand = null; - }, 5000); - - const { - commandFinished, - ...requestPayload - } = payload; - - const promise = createAjaxRequest({ - url: '/command', - method: 'POST', - data: JSON.stringify(requestPayload), - dataType: 'json' - }).request; - - return promise.then((data) => { - if (commandFinished) { - commandFinishedCallbacks[data.id] = commandFinished; - } - - dispatch(addCommand(data)); - }); -} - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - [FETCH_COMMANDS]: createFetchHandler('commands', '/command'), - - [EXECUTE_COMMAND]: function(getState, payload, dispatch) { - executeCommandHelper(payload, dispatch); - }, - - [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'), - - [ADD_COMMAND]: function(getState, payload, dispatch) { - dispatch(updateItem({ section: 'commands', ...payload })); - }, - - [UPDATE_COMMAND]: function(getState, payload, dispatch) { - dispatch(updateItem({ section: 'commands', ...payload })); - - showCommandMessage(payload, dispatch); - scheduleRemoveCommand(payload, dispatch); - }, - - [FINISH_COMMAND]: function(getState, payload, dispatch) { - const state = getState(); - const handlers = state.commands.handlers; - - Object.keys(handlers).forEach((key) => { - const handler = handlers[key]; - - if (handler.name === payload.name) { - dispatch(handler.handler(payload)); - } - }); - - const commandFinished = commandFinishedCallbacks[payload.id]; - - if (commandFinished) { - commandFinished(payload); - } - - delete commandFinishedCallbacks[payload.id]; - - dispatch(updateItem({ section: 'commands', ...payload })); - scheduleRemoveCommand(payload, dispatch); - showCommandMessage(payload, dispatch); - }, - - [REMOVE_COMMAND]: function(getState, payload, dispatch) { - dispatch(removeItem({ section: 'commands', ...payload })); - } - -}); - -// -// Reducers - -export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 17acbf17e..fa68a8cd8 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -1,5 +1,4 @@ import * as captcha from './captchaActions'; -import * as commands from './commandActions'; import * as episodeHistory from './episodeHistoryActions'; import * as importSeries from './importSeriesActions'; import * as interactiveImportActions from './interactiveImportActions'; @@ -11,7 +10,6 @@ import * as settings from './settingsActions'; export default [ captcha, - commands, episodeHistory, importSeries, interactiveImportActions, diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.ts b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts deleted file mode 100644 index 634fa847f..000000000 --- a/frontend/src/Store/Selectors/createCommandExecutingSelector.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createSelector } from 'reselect'; -import { isCommandExecuting } from 'Utilities/Command'; -import createCommandSelector from './createCommandSelector'; - -function createCommandExecutingSelector(name: string, constraints = {}) { - return createSelector(createCommandSelector(name, constraints), (command) => { - return command ? isCommandExecuting(command) : false; - }); -} - -export default createCommandExecutingSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.ts b/frontend/src/Store/Selectors/createCommandSelector.ts deleted file mode 100644 index 1cc34201b..000000000 --- a/frontend/src/Store/Selectors/createCommandSelector.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createSelector } from 'reselect'; -import { findCommand } from 'Utilities/Command'; -import createCommandsSelector from './createCommandsSelector'; - -function createCommandSelector(name: string, constraints = {}) { - return createSelector(createCommandsSelector(), (commands) => { - return findCommand(commands, { name, ...constraints }); - }); -} - -export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandsSelector.ts b/frontend/src/Store/Selectors/createCommandsSelector.ts deleted file mode 100644 index 2dd5d24a2..000000000 --- a/frontend/src/Store/Selectors/createCommandsSelector.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; - -function createCommandsSelector() { - return createSelector( - (state: AppState) => state.commands, - (commands) => { - return commands.items; - } - ); -} - -export default createCommandsSelector; diff --git a/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts deleted file mode 100644 index dd16571fc..000000000 --- a/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; -import { isCommandExecuting } from 'Utilities/Command'; - -function createExecutingCommandsSelector() { - return createSelector( - (state: AppState) => state.commands.items, - (commands) => { - return commands.filter((command) => isCommandExecuting(command)); - } - ); -} - -export default createExecutingCommandsSelector; diff --git a/frontend/src/System/Backup/Backups.tsx b/frontend/src/System/Backup/Backups.tsx index 4dc2272ea..3d58dadc5 100644 --- a/frontend/src/System/Backup/Backups.tsx +++ b/frontend/src/System/Backup/Backups.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; @@ -13,8 +13,6 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { icons, kinds } from 'Helpers/Props'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import translate from 'Utilities/String/translate'; import BackupRow from './BackupRow'; import RestoreBackupModal from './RestoreBackupModal'; @@ -49,12 +47,10 @@ const columns: Column[] = [ ]; function Backups() { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { data: items, isLoading: isFetching, error, refetch } = useBackups(); - const isBackupExecuting = useSelector( - createCommandExecutingSelector(commandNames.BACKUP) - ); + const isBackupExecuting = useCommandExecuting(CommandNames.Backup); const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); @@ -63,12 +59,10 @@ function Backups() { const noBackups = !items.length && !isFetching && !error; const handleBackupPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.BACKUP, - }) - ); - }, [dispatch]); + executeCommand({ + name: CommandNames.Backup, + }); + }, [executeCommand]); const handleRestorePress = useCallback(() => { setIsRestoreModalOpen(true); diff --git a/frontend/src/System/Events/LogsTable.tsx b/frontend/src/System/Events/LogsTable.tsx index 8d5a5511c..128726857 100644 --- a/frontend/src/System/Events/LogsTable.tsx +++ b/frontend/src/System/Events/LogsTable.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FilterMenu from 'Components/Menu/FilterMenu'; @@ -15,8 +15,6 @@ import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptions import TablePager from 'Components/Table/TablePager'; import { align, icons, kinds } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { TableOptionsChangePayload } from 'typings/Table'; import translate from 'Utilities/String/translate'; import { @@ -29,7 +27,7 @@ import LogsTableRow from './LogsTableRow'; import useEvents, { useFilters } from './useEvents'; function LogsTable() { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { records, totalPages, @@ -47,9 +45,7 @@ function LogsTable() { const filters = useFilters(); - const isClearLogExecuting = useSelector( - createCommandExecutingSelector(commandNames.CLEAR_LOGS) - ); + const isClearLogExecuting = useCommandExecuting(CommandNames.ClearLog); const handleFilterSelect = useCallback( (selectedFilterKey: string | number) => { @@ -84,15 +80,15 @@ function LogsTable() { }, [goToPage]); const handleClearLogsPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.CLEAR_LOGS, - commandFinished: () => { - goToPage(1); - }, - }) + executeCommand( + { + name: CommandNames.ClearLog, + }, + () => { + goToPage(1); + } ); - }, [dispatch, goToPage]); + }, [executeCommand, goToPage]); return ( diff --git a/frontend/src/System/Logs/App/AppLogFiles.tsx b/frontend/src/System/Logs/App/AppLogFiles.tsx index 7225ffa76..3cd64e809 100644 --- a/frontend/src/System/Logs/App/AppLogFiles.tsx +++ b/frontend/src/System/Logs/App/AppLogFiles.tsx @@ -1,17 +1,15 @@ import React, { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import LogFiles from '../LogFiles'; import useLogFiles from '../useLogFiles'; function AppLogFiles() { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { data = [], isFetching, refetch } = useLogFiles(); - const isDeleteFilesExecuting = useSelector( - createCommandExecutingSelector(commandNames.DELETE_LOG_FILES) + const isDeleteFilesExecuting = useCommandExecuting( + CommandNames.DeleteLogFiles ); const handleRefreshPress = useCallback(() => { @@ -19,15 +17,15 @@ function AppLogFiles() { }, [refetch]); const handleDeleteFilesPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.DELETE_LOG_FILES, - commandFinished: () => { - refetch(); - }, - }) + executeCommand( + { + name: CommandNames.DeleteLogFiles, + }, + () => { + refetch(); + } ); - }, [dispatch, refetch]); + }, [executeCommand, refetch]); return ( { @@ -19,15 +17,15 @@ function UpdateLogFiles() { }, [refetch]); const handleDeleteFilesPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.DELETE_UPDATE_LOG_FILES, - commandFinished: () => { - refetch(); - }, - }) + executeCommand( + { + name: CommandNames.DeleteUpdateLogFiles, + }, + () => { + refetch(); + } ); - }, [dispatch, refetch]); + }, [executeCommand, refetch]); return ( { - dispatch(cancelCommand({ id })); - }, [id, dispatch]); + cancelCommand(); + }, [cancelCommand]); useEffect(() => { updateTimeTimeoutId.current = setTimeout(() => { diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx index 26ae06928..7a75bee4b 100644 --- a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx @@ -32,9 +32,9 @@ export default function QueuedTaskRowNameCell( props: QueuedTaskRowNameCellProps ) { const { commandName, body, clientUserAgent } = props; - const seriesIds = [...(body.seriesIds ?? [])]; + const seriesIds = 'seriesIds' in body ? [...body.seriesIds] : []; - if (body.seriesId) { + if ('seriesId' in body && body.seriesId) { seriesIds.push(body.seriesId); } @@ -48,7 +48,7 @@ export default function QueuedTaskRowNameCell( {sortedSeries.length ? ( - {formatTitles(sortedSeries.map((s) => s.title))} ) : null} - {body.seasonNumber ? ( + {'seasonNumber' in body && body.seasonNumber ? ( {' '} {translate('SeasonNumberToken', { diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.tsx b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx index ec4438b00..25305d3e1 100644 --- a/frontend/src/System/Tasks/Queued/QueuedTasks.tsx +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx @@ -1,12 +1,10 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import React from 'react'; +import { useCommands } from 'Commands/useCommands'; import FieldSet from 'Components/FieldSet'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; -import { fetchCommands } from 'Store/Actions/commandActions'; import translate from 'Utilities/String/translate'; import QueuedTaskRow from './QueuedTaskRow'; @@ -49,28 +47,33 @@ const columns: Column[] = [ ]; export default function QueuedTasks() { - const dispatch = useDispatch(); - const { isFetching, isPopulated, items } = useSelector( - (state: AppState) => state.commands - ); + const { data: commands, isLoading, isError } = useCommands(); - useEffect(() => { - dispatch(fetchCommands()); - }, [dispatch]); + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+
Error loading commands
+
+ ); + } return (
- {isFetching && !isPopulated && } - - {isPopulated && ( - - - {items.map((item) => { - return ; - })} - -
- )} + + + {commands.map((item) => { + return ; + })} + +
); } diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx index 08716240a..c59c22541 100644 --- a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.tsx @@ -1,12 +1,11 @@ import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { useCommand, useExecuteCommand } from 'Commands/useCommands'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; import { icons } from 'Helpers/Props'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandSelector from 'Store/Selectors/createCommandSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import { isCommandExecuting } from 'Utilities/Command'; import formatDate from 'Utilities/Date/formatDate'; @@ -34,10 +33,10 @@ function ScheduledTaskRow({ lastDuration, nextExecution, }: ScheduledTaskRowProps) { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { showRelativeDates, longDateFormat, shortDateFormat, timeFormat } = useSelector(createUISettingsSelector()); - const command = useSelector(createCommandSelector(taskName)); + const command = useCommand(taskName); const [time, setTime] = useState(Date.now()); @@ -81,8 +80,8 @@ function ScheduledTaskRow({ ]); const handleExecutePress = useCallback(() => { - dispatch(executeCommand({ name: taskName })); - }, [taskName, dispatch]); + executeCommand({ name: taskName }); + }, [taskName, executeCommand]); useEffect(() => { const interval = setInterval(() => setTime(Date.now()), 1000); diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx index f3975e6de..6208a9922 100644 --- a/frontend/src/System/Updates/Updates.tsx +++ b/frontend/src/System/Updates/Updates.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useAppValue } from 'App/appStore'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import Icon from 'Components/Icon'; import Label from 'Components/Label'; @@ -13,9 +14,7 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { icons, kinds } from 'Helpers/Props'; import useUpdateSettings from 'Settings/General/useUpdateSettings'; -import { executeCommand } from 'Store/Actions/commandActions'; import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import { useSystemStatusData } from 'System/Status/useSystemStatus'; import { UpdateMechanism } from 'typings/Settings/General'; @@ -35,8 +34,8 @@ function Updates() { const { shortDateFormat, longDateFormat, timeFormat } = useSelector( createUISettingsSelector() ); - const isInstallingUpdate = useSelector( - createCommandExecutingSelector(commandNames.APPLICATION_UPDATE) + const isInstallingUpdate = useCommandExecuting( + CommandNames.ApplicationUpdate ); const { @@ -53,6 +52,7 @@ function Updates() { } = useUpdateSettings(); const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false); const isFetching = isLoadingUpdates || isLoadingSettings; const isPopulated = isUpdatesFetched && isSettingsFetched; @@ -92,20 +92,18 @@ function Updates() { if (isMajorUpdate) { setIsMajorUpdateModalOpen(true); } else { - dispatch(executeCommand({ name: commandNames.APPLICATION_UPDATE })); + executeCommand({ name: CommandNames.ApplicationUpdate }); } - }, [isMajorUpdate, setIsMajorUpdateModalOpen, dispatch]); + }, [isMajorUpdate, setIsMajorUpdateModalOpen, executeCommand]); const handleInstallLatestMajorVersionPress = useCallback(() => { setIsMajorUpdateModalOpen(false); - dispatch( - executeCommand({ - name: commandNames.APPLICATION_UPDATE, - installMajorUpdate: true, - }) - ); - }, [setIsMajorUpdateModalOpen, dispatch]); + executeCommand({ + name: CommandNames.ApplicationUpdate, + installMajorUpdate: true, + }); + }, [setIsMajorUpdateModalOpen, executeCommand]); const handleCancelMajorVersionPress = useCallback(() => { setIsMajorUpdateModalOpen(false); diff --git a/frontend/src/Utilities/Command/isSameCommand.ts b/frontend/src/Utilities/Command/isSameCommand.ts index cbe18aa8f..aebcd1930 100644 --- a/frontend/src/Utilities/Command/isSameCommand.ts +++ b/frontend/src/Utilities/Command/isSameCommand.ts @@ -3,42 +3,35 @@ import { CommandBody } from 'Commands/Command'; function isSameCommand( commandA: Partial, commandB: Partial -) { - if ( - commandA.name?.toLocaleLowerCase() !== commandB.name?.toLocaleLowerCase() - ) { +): boolean { + if (commandA.name?.toLowerCase() !== commandB.name?.toLowerCase()) { return false; } for (const key in commandB) { if (key !== 'name') { - const value = commandB[key]; + const valueB = commandB[key as keyof CommandBody]; + const valueA = commandA[key as keyof CommandBody]; - if (Array.isArray(value)) { - const sortedB = [...value].sort((a, b) => a - b); - const commandAProp = commandA[key]; - const sortedA = Array.isArray(commandAProp) - ? [...commandAProp].sort((a, b) => a - b) - : []; - - if (sortedA === sortedB) { - return true; - } - - if (sortedA == null || sortedB == null) { + if (Array.isArray(valueB)) { + if (!Array.isArray(valueA)) { return false; } + // Sort both arrays for comparison + const sortedB = [...(valueB as number[])].sort((a, b) => a - b); + const sortedA = [...(valueA as number[])].sort((a, b) => a - b); + if (sortedA.length !== sortedB.length) { return false; } - for (let i = 0; i < sortedB.length; ++i) { + for (let i = 0; i < sortedB.length; i++) { if (sortedB[i] !== sortedA[i]) { return false; } } - } else if (value !== commandA[key]) { + } else if (valueB !== valueA) { return false; } } diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.tsx b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.tsx index 3d94b884b..f514247c4 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.tsx +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.tsx @@ -5,10 +5,10 @@ import React, { useMemo, useState, } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FilterMenu from 'Components/Menu/FilterMenu'; @@ -29,8 +29,6 @@ import EpisodeFileProvider from 'EpisodeFile/EpisodeFileProvider'; import { Filter } from 'Filters/Filter'; import { align, icons, kinds } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { CheckInputChanged } from 'typings/inputs'; import { TableOptionsChangePayload } from 'typings/Table'; import getFilterValue from 'Utilities/Filter/getFilterValue'; @@ -57,7 +55,7 @@ function getMonitoredValue( } function CutoffUnmetContent() { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { records, @@ -74,11 +72,11 @@ function CutoffUnmetContent() { const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = useCutoffUnmetOptions(); - const isSearchingForAllEpisodes = useSelector( - createCommandExecutingSelector(commandNames.CUTOFF_UNMET_EPISODE_SEARCH) + const isSearchingForAllEpisodes = useCommandExecuting( + CommandNames.CutoffUnmetEpisodeSearch ); - const isSearchingForSelectedEpisodes = useSelector( - createCommandExecutingSelector(commandNames.EPISODE_SEARCH) + const isSearchingForSelectedEpisodes = useCommandExecuting( + CommandNames.EpisodeSearch ); const { @@ -121,16 +119,16 @@ function CutoffUnmetContent() { ); const handleSearchSelectedPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.EPISODE_SEARCH, + executeCommand( + { + name: CommandNames.EpisodeSearch, episodeIds: getSelectedIds(), - commandFinished: () => { - refetch(); - }, - }) + }, + () => { + refetch(); + } ); - }, [getSelectedIds, dispatch, refetch]); + }, [getSelectedIds, executeCommand, refetch]); const handleSearchAllPress = useCallback(() => { setIsConfirmSearchAllModalOpen(true); @@ -141,17 +139,17 @@ function CutoffUnmetContent() { }, []); const handleSearchAllCutoffUnmetConfirmed = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.CUTOFF_UNMET_EPISODE_SEARCH, - commandFinished: () => { - refetch(); - }, - }) + executeCommand( + { + name: CommandNames.CutoffUnmetEpisodeSearch, + }, + () => { + refetch(); + } ); setIsConfirmSearchAllModalOpen(false); - }, [dispatch, refetch]); + }, [executeCommand, refetch]); const handleToggleSelectedPress = useCallback(() => { toggleEpisodesMonitored({ diff --git a/frontend/src/Wanted/Missing/Missing.tsx b/frontend/src/Wanted/Missing/Missing.tsx index 54846ed85..0e7996601 100644 --- a/frontend/src/Wanted/Missing/Missing.tsx +++ b/frontend/src/Wanted/Missing/Missing.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider'; import { SelectProvider, useSelect } from 'App/Select/SelectContext'; -import * as commandNames from 'Commands/commandNames'; +import CommandNames from 'Commands/CommandNames'; +import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import FilterMenu from 'Components/Menu/FilterMenu'; @@ -23,8 +23,6 @@ import { Filter } from 'Filters/Filter'; import { align, icons, kinds } from 'Helpers/Props'; import { SortDirection } from 'Helpers/Props/sortDirections'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { CheckInputChanged } from 'typings/inputs'; import { TableOptionsChangePayload } from 'typings/Table'; import getFilterValue from 'Utilities/Filter/getFilterValue'; @@ -51,7 +49,7 @@ function getMonitoredValue( } function MissingContent() { - const dispatch = useDispatch(); + const executeCommand = useExecuteCommand(); const { records, @@ -68,11 +66,11 @@ function MissingContent() { const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = useMissingOptions(); - const isSearchingForAllEpisodes = useSelector( - createCommandExecutingSelector(commandNames.MISSING_EPISODE_SEARCH) + const isSearchingForAllEpisodes = useCommandExecuting( + CommandNames.MissingEpisodeSearch ); - const isSearchingForSelectedEpisodes = useSelector( - createCommandExecutingSelector(commandNames.EPISODE_SEARCH) + const isSearchingForSelectedEpisodes = useCommandExecuting( + CommandNames.EpisodeSearch ); const { @@ -114,16 +112,16 @@ function MissingContent() { ); const handleSearchSelectedPress = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.EPISODE_SEARCH, + executeCommand( + { + name: CommandNames.EpisodeSearch, episodeIds: getSelectedIds(), - commandFinished: () => { - refetch(); - }, - }) + }, + () => { + refetch(); + } ); - }, [getSelectedIds, dispatch, refetch]); + }, [getSelectedIds, executeCommand, refetch]); const handleSearchAllPress = useCallback(() => { setIsConfirmSearchAllModalOpen(true); @@ -134,17 +132,17 @@ function MissingContent() { }, []); const handleSearchAllMissingConfirmed = useCallback(() => { - dispatch( - executeCommand({ - name: commandNames.MISSING_EPISODE_SEARCH, - commandFinished: () => { - refetch(); - }, - }) + executeCommand( + { + name: CommandNames.MissingEpisodeSearch, + }, + () => { + refetch(); + } ); setIsConfirmSearchAllModalOpen(false); - }, [dispatch, refetch]); + }, [executeCommand, refetch]); const handleToggleSelectedPress = useCallback(() => { toggleEpisodesMonitored({