1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-18 21:35:27 -04:00

Use react-query for commands

This commit is contained in:
Mark McDowall
2025-12-02 20:31:24 -08:00
parent dd12b9e076
commit dec6f4b5f2
51 changed files with 872 additions and 942 deletions
+93 -2
View File
@@ -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<string, unknown>;
language: Record<string, unknown>;
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<T extends keyof CommandBodyMap> =
CommandBodyMap[T];
interface Command extends ModelBase {
name: string;
commandName: string;
+25
View File
@@ -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;
-21
View File
@@ -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';
+240
View File
@@ -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<number, (command: Command) => void> = {};
export const useCommands = () => {
const result = useApiQuery<Command[]>({
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<Command, NewCommandBody>({
method: 'POST',
path: '/command',
mutationOptions: {
onSuccess: (newCommand: Command) => {
queryClient.setQueryData<Command[]>(
['/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<void, void>({
method: 'DELETE',
path: `/command/${id}`,
mutationOptions: {
onSuccess: () => {
queryClient.setQueryData<Command[]>(
['/command'],
(oldCommands = []) => {
return oldCommands.filter((command) => command.id !== id);
}
);
},
},
});
return {
cancelCommand: mutate,
isCancellingCommand: isPending,
commandCancelError: error,
};
};
export const useCommand = (
commandName: string,
constraints: Partial<CommandBody> = {}
) => {
const { data: commands } = useCommands();
return useMemo(() => {
return commands.find((command) => {
if (command.name !== commandName) {
return undefined;
}
return (Object.keys(constraints) as Array<keyof CommandBody>).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<CommandBody> = {}
) => {
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[]>(['/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,
});
}