Add background task support for UI. Let subtitle modification runs in background

This commit is contained in:
LASER-Yi 2021-08-22 00:44:52 +08:00
parent eb47356d10
commit 9c8119df3b
13 changed files with 189 additions and 144 deletions

View File

@ -57,6 +57,6 @@ declare namespace SocketIO {
};
namespace CustomEvent {
type Progress = Server.Progress;
type Progress = Site.Progress;
}
}

View File

@ -0,0 +1,13 @@
import BGT from "./";
export function useIsAnyTaskRunning() {
return BGT.isRunning();
}
export function useIsGroupTaskRunning(groupName: string) {
return BGT.has(groupName);
}
export function useIsIdRunning(groupName: string, id: number) {
return BGT.find(groupName, id);
}

View File

@ -0,0 +1,62 @@
import { keys } from "lodash";
import { siteAddProgress, siteRemoveProgress } from "../../@redux/actions";
import store from "../../@redux/store";
// A background task manager, use for dispatching task one by one
class BackgroundTask {
private groups: Task.Group;
constructor() {
this.groups = {};
}
dispatch<T extends Task.Callable>(groupName: string, tasks: Task.Task<T>[]) {
if (groupName in this.groups) {
return false;
}
this.groups[groupName] = tasks;
setTimeout(async () => {
const dispatch = store.dispatch;
for (let index = 0; index < tasks.length; index++) {
const task = tasks[index];
dispatch(
siteAddProgress([
{
id: groupName,
header: groupName,
name: task.name,
value: index,
count: tasks.length,
},
])
);
try {
await task.callable(...task.parameters);
} catch (error) {}
}
delete this.groups[groupName];
dispatch(siteRemoveProgress([groupName]));
});
return true;
}
find(groupName: string, id: number) {
if (groupName in this.groups) {
return this.groups[groupName].find((v) => v.id === id) !== undefined;
}
return false;
}
has(groupName: string) {
return groupName in this.groups;
}
isRunning() {
return keys(this.groups).length > 0;
}
}
export default new BackgroundTask();

14
frontend/src/@modules/task/task.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
declare namespace Task {
type Callable = (...args: any[]) => Promise<void>;
interface Task<FN extends Callable> {
name: string;
id?: number;
callable: FN;
parameters: Parameters<FN>;
}
type Group = {
[category: string]: Task.Task<Callable>[];
};
}

View File

@ -0,0 +1,13 @@
export function createTask<T extends Task.Callable>(
name: string,
id: number | undefined,
callable: T,
...parameters: Parameters<T>
): Task.Task<T> {
return {
name,
id,
callable,
parameters,
};
}

View File

@ -28,7 +28,7 @@ export const siteRemoveNotifications = createAction<string>(
);
export const siteAddProgress =
createAction<Server.Progress[]>("site/progress/add");
createAction<Site.Progress[]>("site/progress/add");
export const siteRemoveProgress = createAsyncThunk(
"site/progress/remove",

View File

@ -18,7 +18,7 @@ interface Site {
// Initialization state or error message
initialized: boolean | string;
auth: boolean;
progress: Server.Progress[];
progress: Site.Progress[];
notifications: Server.Notification[];
sidebar: string;
badges: Badge;

View File

@ -5,7 +5,9 @@ declare namespace Server {
message: string;
timeout: number;
}
}
declare namespace Site {
interface Progress {
id: string;
header: string;

View File

@ -36,7 +36,7 @@ enum State {
Failed,
}
function useTotalProgress(progress: Server.Progress[]) {
function useTotalProgress(progress: Site.Progress[]) {
return useMemo(() => {
const { value, count } = progress.reduce(
(prev, { value, count }) => {
@ -50,7 +50,7 @@ function useTotalProgress(progress: Server.Progress[]) {
if (count === 0) {
return 0;
} else {
return value / count;
return (value + 0.001) / count;
}
}, [progress]);
}
@ -196,13 +196,14 @@ const Notification: FunctionComponent<Server.Notification> = ({
);
};
const Progress: FunctionComponent<Server.Progress> = ({
const Progress: FunctionComponent<Site.Progress> = ({
name,
value,
count,
header,
}) => {
const isCompleted = value / count > 1;
const displayValue = Math.min(count, value + 1);
return (
<div className="notification-center-progress d-flex flex-column">
<p className="progress-header m-0 h-6 text-dark font-weight-bold">
@ -214,9 +215,9 @@ const Progress: FunctionComponent<Server.Progress> = ({
<ProgressBar
className="mt-2"
animated={!isCompleted}
now={value / count}
now={displayValue / count}
max={1}
label={`${value}/${count}`}
label={`${displayValue}/${count}`}
></ProgressBar>
</div>
);

View File

@ -23,9 +23,11 @@ export const BaseModal: FunctionComponent<BaseModalProps> = (props) => {
}, []);
const exit = useCallback(() => {
closeModal(modalKey);
if (isShow) {
closeModal(modalKey);
}
setExit(false);
}, [closeModal, modalKey]);
}, [closeModal, modalKey, isShow]);
return (
<Modal

View File

@ -1,7 +1,4 @@
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
import {
faCheck,
faCircleNotch,
faClock,
faCode,
faDeaf,
@ -39,16 +36,19 @@ import {
LanguageText,
Selector,
SimpleTable,
useCloseModalIfCovered,
useModalPayload,
useShowModal,
} from "..";
import BackgroundTask from "../../@modules/task";
import { useIsGroupTaskRunning } from "../../@modules/task/hooks";
import { createTask } from "../../@modules/task/utilites";
import { useEnabledLanguages } from "../../@redux/hooks";
import { SubtitlesApi } from "../../apis";
import { isMovie, submodProcessColor } from "../../utilites";
import { log } from "../../utilites/logger";
import { useCustomSelection } from "../tables/plugins";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useCloseModal } from "./hooks";
import { availableTranslation, colorOptions } from "./toolOptions";
type SupportType = Item.Episode | Item.Movie;
@ -57,40 +57,6 @@ type TableColumnType = FormType.ModifySubtitle & {
_language: Language.Info;
};
enum State {
Pending,
Processing,
Done,
}
type ProcessState = StrictObject<State>;
// TODO: Extract this
interface StateIconProps {
state: State;
}
const StateIcon: FunctionComponent<StateIconProps> = ({ state }) => {
let icon = faQuestionCircle;
switch (state) {
case State.Pending:
icon = faClock;
break;
case State.Processing:
icon = faCircleNotch;
break;
case State.Done:
icon = faCheck;
break;
}
return (
<FontAwesomeIcon
icon={icon}
spin={state === State.Processing}
></FontAwesomeIcon>
);
};
function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
if (isMovie(item)) {
return [item.radarrId, "movie"];
@ -328,51 +294,39 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({
);
};
interface STMProps {}
const TaskGroupName = "Modifying Subtitles";
const STM: FunctionComponent<BaseModalProps & STMProps> = ({ ...props }) => {
const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
const payload = useModalPayload<SupportType[]>(props.modalKey);
const [updating, setUpdate] = useState<boolean>(false);
const [processState, setProcessState] = useState<ProcessState>({});
const [selections, setSelections] = useState<TableColumnType[]>([]);
const closeModal = useCloseModalIfCovered();
const hasTask = useIsGroupTaskRunning(TaskGroupName);
const closeModal = useCloseModal();
const process = useCallback(
async (action: string, override?: Partial<FormType.ModifySubtitle>) => {
(action: string, override?: Partial<FormType.ModifySubtitle>) => {
log("info", "executing action", action);
closeModal(props.modalKey);
setUpdate(true);
let states = selections.reduce<ProcessState>(
(v, curr) => ({ [curr.path]: State.Pending, ...v }),
{}
);
setProcessState(states);
for (const raw of selections) {
states = {
...states,
[raw.path]: State.Processing,
};
setProcessState(states);
const tasks = selections.map((s) => {
const form: FormType.ModifySubtitle = {
id: raw.id,
type: raw.type,
language: raw.language,
path: raw.path,
id: s.id,
type: s.type,
language: s.language,
path: s.path,
...override,
};
await SubtitlesApi.modify(action, form);
return createTask(
s.path,
s.id,
SubtitlesApi.modify.bind(SubtitlesApi),
action,
form
);
});
states = {
...states,
[raw.path]: State.Done,
};
setProcessState(states);
}
setUpdate(false);
BackgroundTask.dispatch(TaskGroupName, tasks);
},
[closeModal, selections, props.modalKey]
);
@ -381,21 +335,6 @@ const STM: FunctionComponent<BaseModalProps & STMProps> = ({ ...props }) => {
const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
() => [
{
id: "state",
accessor: "path",
selectHide: true,
Cell: ({ value, loose }) => {
if (loose) {
const stateList = loose[0] as ProcessState;
if (value in stateList) {
const state = stateList[value];
return <StateIcon state={state}></StateIcon>;
}
}
return null;
},
},
{
Header: "Language",
accessor: "_language",
@ -459,15 +398,14 @@ const STM: FunctionComponent<BaseModalProps & STMProps> = ({ ...props }) => {
<Dropdown as={ButtonGroup} onSelect={(k) => k && process(k)}>
<ActionButton
size="sm"
loading={updating}
disabled={selections.length === 0}
disabled={selections.length === 0 || hasTask}
icon={faPlay}
onClick={() => process("sync")}
>
Sync
</ActionButton>
<Dropdown.Toggle
disabled={updating || selections.length === 0}
disabled={selections.length === 0 || hasTask}
split
variant="light"
size="sm"
@ -511,25 +449,19 @@ const STM: FunctionComponent<BaseModalProps & STMProps> = ({ ...props }) => {
</Dropdown.Menu>
</Dropdown>
),
[showModal, updating, selections.length, process]
[showModal, selections.length, process, hasTask]
);
return (
<React.Fragment>
<BaseModal
title={"Subtitle Tools"}
footer={footer}
closeable={!updating}
{...props}
>
<BaseModal title={"Subtitle Tools"} footer={footer} {...props}>
<SimpleTable
isSelecting={!updating && data.length !== 0}
isSelecting={data.length !== 0}
emptyText="No External Subtitles Found"
plugins={plugins}
columns={columns}
onSelect={setSelections}
data={data}
loose={[processState]}
></SimpleTable>
</BaseModal>
<AddColorModal process={process} modalKey="add-color"></AddColorModal>

View File

@ -41,46 +41,16 @@ export function useShowModal() {
export function useCloseModal() {
const {
control: { pop, peek },
control: { pop },
} = useContext(ModalContext);
return useCallback(
(key?: string) => {
const modal = peek();
if (key) {
if (modal?.key === key) {
pop();
}
} else {
pop();
}
pop(key);
},
[pop, peek]
[pop]
);
}
export function useCloseModalIfCovered() {
const {
control: { pop, peek },
} = useContext(ModalContext);
return useCallback(
(key: string) => {
let modal = peek();
if (modal && modal.key !== key) {
pop();
}
},
[pop, peek]
);
}
export function useModalIsCovered(key: string) {
const { modals } = useContext(ModalContext);
return useMemo(() => {
const idx = modals.findIndex((v) => v.key === key);
return idx !== -1 && idx !== 0;
}, [modals, key]);
}
export function useIsModalShow(key: string) {
const {
control: { peek },

View File

@ -1,5 +1,9 @@
import React, { FunctionComponent, useMemo } from "react";
import { useStackState } from "rooks";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
interface Modal {
key: string;
@ -9,7 +13,7 @@ interface Modal {
interface ModalControl {
push: (modal: Modal) => void;
peek: () => Modal | undefined;
pop: () => Modal | undefined;
pop: (key: string | undefined) => void;
}
interface ModalContextType {
@ -33,7 +37,39 @@ export const ModalContext = React.createContext<ModalContextType>({
});
const ModalProvider: FunctionComponent = ({ children }) => {
const [stack, { push, pop, peek }] = useStackState([]);
const [stack, setStack] = useState<Modal[]>([]);
const push = useCallback<ModalControl["push"]>((model) => {
setStack((old) => {
return [...old, model];
});
}, []);
const pop = useCallback<ModalControl["pop"]>((key) => {
setStack((old) => {
if (old.length === 0) {
return [];
}
if (key === undefined) {
const newOld = old;
newOld.pop();
return newOld;
}
// find key
const index = old.findIndex((v) => v.key === key);
if (index !== -1) {
return old.slice(0, index);
} else {
return old;
}
});
}, []);
const peek = useCallback<ModalControl["peek"]>(() => {
return stack.length > 0 ? stack[stack.length - 1] : undefined;
}, [stack]);
const context = useMemo<ModalContextType>(
() => ({ modals: stack, control: { push, pop, peek } }),