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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user