summaryrefslogtreecommitdiffhomepage
path: root/frontend/src
diff options
context:
space:
mode:
authorLASER-Yi <[email protected]>2022-03-27 16:03:04 +0800
committerLASER-Yi <[email protected]>2022-03-27 16:03:04 +0800
commite18657e4261cae67d6fe5a235a001dede26721c5 (patch)
treeca281c608d0e3222fb0448803bea170528fbbdba /frontend/src
parent658237dd5076a3d4823552ad17c101d3ba6177fc (diff)
downloadbazarr-e18657e4261cae67d6fe5a235a001dede26721c5.tar.gz
bazarr-e18657e4261cae67d6fe5a235a001dede26721c5.zip
Improve subtitle tools
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/components/modals/SubtitleToolModal.tsx453
-rw-r--r--frontend/src/components/modals/index.ts1
-rw-r--r--frontend/src/components/modals/subtitle-tools/ColorTool.tsx36
-rw-r--r--frontend/src/components/modals/subtitle-tools/FrameRateTool.tsx65
-rw-r--r--frontend/src/components/modals/subtitle-tools/TimeTool.tsx100
-rw-r--r--frontend/src/components/modals/subtitle-tools/ToolContext.ts14
-rw-r--r--frontend/src/components/modals/subtitle-tools/Translation.tsx48
-rw-r--r--frontend/src/components/modals/subtitle-tools/index.tsx230
-rw-r--r--frontend/src/components/modals/subtitle-tools/tools.ts80
-rw-r--r--frontend/src/components/modals/subtitle-tools/types.d.ts9
-rw-r--r--frontend/src/pages/Episodes/index.tsx7
-rw-r--r--frontend/src/pages/Episodes/table.tsx7
-rw-r--r--frontend/src/pages/Movies/Details/index.tsx6
13 files changed, 593 insertions, 463 deletions
diff --git a/frontend/src/components/modals/SubtitleToolModal.tsx b/frontend/src/components/modals/SubtitleToolModal.tsx
deleted file mode 100644
index 823aca5a7..000000000
--- a/frontend/src/components/modals/SubtitleToolModal.tsx
+++ /dev/null
@@ -1,453 +0,0 @@
-import { useSubtitleAction } from "@/apis/hooks";
-import {
- useModal,
- useModalControl,
- usePayload,
- withModal,
-} from "@/modules/modals";
-import { createTask, dispatchTask } from "@/modules/task/utilities";
-import { isMovie, submodProcessColor } from "@/utilities";
-import { LOG } from "@/utilities/console";
-import { useEnabledLanguages } from "@/utilities/languages";
-import {
- faClock,
- faCode,
- faDeaf,
- faExchangeAlt,
- faFilm,
- faImage,
- faLanguage,
- faMagic,
- faMinus,
- faPaintBrush,
- faPlay,
- faPlus,
- faTextHeight,
-} from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- ChangeEventHandler,
- FunctionComponent,
- useCallback,
- useMemo,
- useState,
-} from "react";
-import {
- Badge,
- Button,
- ButtonGroup,
- Dropdown,
- Form,
- InputGroup,
-} from "react-bootstrap";
-import { Column, useRowSelect } from "react-table";
-import {
- ActionButton,
- ActionButtonItem,
- LanguageSelector,
- Selector,
- SimpleTable,
-} from "..";
-import Language from "../bazarr/Language";
-import { useCustomSelection } from "../tables/plugins";
-import { availableTranslation, colorOptions } from "./toolOptions";
-
-type SupportType = Item.Episode | Item.Movie;
-
-type TableColumnType = FormType.ModifySubtitle & {
- _language: Language.Info;
-};
-
-function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
- if (isMovie(item)) {
- return [item.radarrId, "movie"];
- } else {
- return [item.sonarrEpisodeId, "episode"];
- }
-}
-
-function submodProcessFrameRate(from: number, to: number) {
- return `change_FPS(from=${from},to=${to})`;
-}
-
-function submodProcessOffset(h: number, m: number, s: number, ms: number) {
- return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
-}
-
-interface ToolModalProps {
- process: (
- action: string,
- override?: Partial<FormType.ModifySubtitle>
- ) => void;
-}
-
-const ColorTool: FunctionComponent<ToolModalProps> = ({ process }) => {
- const [selection, setSelection] = useState<Nullable<string>>(null);
-
- const Modal = useModal();
-
- const submit = useCallback(() => {
- if (selection) {
- const action = submodProcessColor(selection);
- process(action);
- }
- }, [selection, process]);
-
- const footer = (
- <Button disabled={selection === null} onClick={submit}>
- Save
- </Button>
- );
-
- return (
- <Modal title="Choose Color" footer={footer}>
- <Selector options={colorOptions} onChange={setSelection}></Selector>
- </Modal>
- );
-};
-
-const ColorToolModal = withModal(ColorTool, "color-tool");
-
-const FrameRateTool: FunctionComponent<ToolModalProps> = ({ process }) => {
- const [from, setFrom] = useState<Nullable<number>>(null);
- const [to, setTo] = useState<Nullable<number>>(null);
-
- const canSave = from !== null && to !== null && from !== to;
-
- const Modal = useModal();
-
- const submit = useCallback(() => {
- if (canSave) {
- const action = submodProcessFrameRate(from, to);
- process(action);
- }
- }, [canSave, from, to, process]);
-
- const footer = (
- <Button disabled={!canSave} onClick={submit}>
- Save
- </Button>
- );
-
- return (
- <Modal title="Change Frame Rate" footer={footer}>
- <InputGroup className="px-2">
- <Form.Control
- placeholder="From"
- type="number"
- onChange={(e) => {
- const value = parseFloat(e.currentTarget.value);
- if (isNaN(value)) {
- setFrom(null);
- } else {
- setFrom(value);
- }
- }}
- ></Form.Control>
- <Form.Control
- placeholder="To"
- type="number"
- onChange={(e) => {
- const value = parseFloat(e.currentTarget.value);
- if (isNaN(value)) {
- setTo(null);
- } else {
- setTo(value);
- }
- }}
- ></Form.Control>
- </InputGroup>
- </Modal>
- );
-};
-
-const FrameRateModal = withModal(FrameRateTool, "frame-rate-tool");
-
-const TimeAdjustmentTool: FunctionComponent<ToolModalProps> = ({ process }) => {
- const [isPlus, setPlus] = useState(true);
- const [offset, setOffset] = useState<[number, number, number, number]>([
- 0, 0, 0, 0,
- ]);
-
- const Modal = useModal();
-
- const updateOffset = useCallback(
- (idx: number): ChangeEventHandler<HTMLInputElement> => {
- return (e) => {
- let value = parseFloat(e.currentTarget.value);
- if (isNaN(value)) {
- value = 0;
- }
- const newOffset = [...offset] as [number, number, number, number];
- newOffset[idx] = value;
- setOffset(newOffset);
- };
- },
- [offset]
- );
-
- const canSave = offset.some((v) => v !== 0);
-
- const submit = useCallback(() => {
- if (canSave) {
- const newOffset = offset.map((v) => (isPlus ? v : -v));
- const action = submodProcessOffset(
- newOffset[0],
- newOffset[1],
- newOffset[2],
- newOffset[3]
- );
- process(action);
- }
- }, [process, canSave, offset, isPlus]);
-
- const footer = (
- <Button disabled={!canSave} onClick={submit}>
- Save
- </Button>
- );
-
- return (
- <Modal title="Adjust Times" footer={footer}>
- <InputGroup>
- <InputGroup.Prepend>
- <Button
- variant="secondary"
- title={isPlus ? "Later" : "Earlier"}
- onClick={() => setPlus(!isPlus)}
- >
- <FontAwesomeIcon icon={isPlus ? faPlus : faMinus}></FontAwesomeIcon>
- </Button>
- </InputGroup.Prepend>
- <Form.Control
- type="number"
- placeholder="hour"
- onChange={updateOffset(0)}
- ></Form.Control>
- <Form.Control
- type="number"
- placeholder="min"
- onChange={updateOffset(1)}
- ></Form.Control>
- <Form.Control
- type="number"
- placeholder="sec"
- onChange={updateOffset(2)}
- ></Form.Control>
- <Form.Control
- type="number"
- placeholder="ms"
- onChange={updateOffset(3)}
- ></Form.Control>
- </InputGroup>
- </Modal>
- );
-};
-
-const TimeAdjustmentModal = withModal(TimeAdjustmentTool, "time-adjust-tool");
-
-const TranslationTool: FunctionComponent<ToolModalProps> = ({ process }) => {
- const { data: languages } = useEnabledLanguages();
-
- const available = useMemo(
- () => languages.filter((v) => v.code2 in availableTranslation),
- [languages]
- );
-
- const Modal = useModal();
-
- const [selectedLanguage, setLanguage] =
- useState<Nullable<Language.Info>>(null);
-
- const submit = useCallback(() => {
- if (selectedLanguage) {
- process("translate", { language: selectedLanguage.code2 });
- }
- }, [selectedLanguage, process]);
-
- const footer = (
- <Button disabled={!selectedLanguage} onClick={submit}>
- Translate
- </Button>
- );
- return (
- <Modal title="Translation" footer={footer}>
- <Form.Label>
- Enabled languages not listed here are unsupported by Google Translate.
- </Form.Label>
- <LanguageSelector
- options={available}
- onChange={setLanguage}
- ></LanguageSelector>
- </Modal>
- );
-};
-
-const TranslationModal = withModal(TranslationTool, "translate-tool");
-
-const CanSelectSubtitle = (item: TableColumnType) => {
- return item.path.endsWith(".srt");
-};
-
-const STM: FunctionComponent = () => {
- const payload = usePayload<SupportType[]>();
- const [selections, setSelections] = useState<TableColumnType[]>([]);
-
- const Modal = useModal({ size: "xl" });
- const { hide } = useModalControl();
-
- const { mutateAsync } = useSubtitleAction();
-
- const process = useCallback(
- (action: string, override?: Partial<FormType.ModifySubtitle>) => {
- LOG("info", "executing action", action);
- hide();
- const tasks = selections.map((s) => {
- const form: FormType.ModifySubtitle = {
- id: s.id,
- type: s.type,
- language: s.language,
- path: s.path,
- ...override,
- };
- return createTask(s.path, mutateAsync, { action, form });
- });
-
- dispatchTask(tasks, "modify-subtitles");
- },
- [hide, selections, mutateAsync]
- );
-
- const { show } = useModalControl();
-
- const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
- () => [
- {
- Header: "Language",
- accessor: "_language",
- Cell: ({ value }) => (
- <Badge variant="secondary">
- <Language.Text value={value} long></Language.Text>
- </Badge>
- ),
- },
- {
- id: "file",
- Header: "File",
- accessor: "path",
- Cell: ({ value }) => {
- const path = value;
-
- let idx = path.lastIndexOf("/");
-
- if (idx === -1) {
- idx = path.lastIndexOf("\\");
- }
-
- if (idx !== -1) {
- return path.slice(idx + 1);
- } else {
- return path;
- }
- },
- },
- ],
- []
- );
-
- const data = useMemo<TableColumnType[]>(
- () =>
- payload?.flatMap((item) => {
- const [id, type] = getIdAndType(item);
- return item.subtitles.flatMap((v) => {
- if (v.path !== null) {
- return [
- {
- id,
- type,
- language: v.code2,
- path: v.path,
- _language: v,
- },
- ];
- } else {
- return [];
- }
- });
- }) ?? [],
- [payload]
- );
-
- const plugins = [useRowSelect, useCustomSelection];
-
- const footer = (
- <Dropdown as={ButtonGroup} onSelect={(k) => k && process(k)}>
- <ActionButton
- size="sm"
- disabled={selections.length === 0}
- icon={faPlay}
- onClick={() => process("sync")}
- >
- Sync
- </ActionButton>
- <Dropdown.Toggle
- disabled={selections.length === 0}
- split
- variant="light"
- size="sm"
- className="px-2"
- ></Dropdown.Toggle>
- <Dropdown.Menu>
- <Dropdown.Item eventKey="remove_HI">
- <ActionButtonItem icon={faDeaf}>Remove HI Tags</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item eventKey="remove_tags">
- <ActionButtonItem icon={faCode}>Remove Style Tags</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item eventKey="OCR_fixes">
- <ActionButtonItem icon={faImage}>OCR Fixes</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item eventKey="common">
- <ActionButtonItem icon={faMagic}>Common Fixes</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item eventKey="fix_uppercase">
- <ActionButtonItem icon={faTextHeight}>Fix Uppercase</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item eventKey="reverse_rtl">
- <ActionButtonItem icon={faExchangeAlt}>Reverse RTL</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item onSelect={() => show(ColorToolModal)}>
- <ActionButtonItem icon={faPaintBrush}>Add Color</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item onSelect={() => show(FrameRateModal)}>
- <ActionButtonItem icon={faFilm}>Change Frame Rate</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item onSelect={() => show(TimeAdjustmentModal)}>
- <ActionButtonItem icon={faClock}>Adjust Times</ActionButtonItem>
- </Dropdown.Item>
- <Dropdown.Item onSelect={() => show(TranslationModal)}>
- <ActionButtonItem icon={faLanguage}>Translate</ActionButtonItem>
- </Dropdown.Item>
- </Dropdown.Menu>
- </Dropdown>
- );
-
- return (
- <Modal title="Subtitle Tools" footer={footer}>
- <SimpleTable
- emptyText="No External Subtitles Found"
- plugins={plugins}
- columns={columns}
- onSelect={setSelections}
- canSelect={CanSelectSubtitle}
- data={data}
- ></SimpleTable>
- <ColorToolModal process={process}></ColorToolModal>
- <FrameRateModal process={process}></FrameRateModal>
- <TimeAdjustmentModal process={process}></TimeAdjustmentModal>
- <TranslationModal process={process}></TranslationModal>
- </Modal>
- );
-};
-
-export default withModal(STM, "subtitle-tools");
diff --git a/frontend/src/components/modals/index.ts b/frontend/src/components/modals/index.ts
index f52d9228d..b5b223abf 100644
--- a/frontend/src/components/modals/index.ts
+++ b/frontend/src/components/modals/index.ts
@@ -2,4 +2,3 @@ export * from "./HistoryModal";
export { default as ItemEditorModal } from "./ItemEditorModal";
export { default as MovieUploadModal } from "./MovieUploadModal";
export { default as SeriesUploadModal } from "./SeriesUploadModal";
-export { default as SubtitleToolModal } from "./SubtitleToolModal";
diff --git a/frontend/src/components/modals/subtitle-tools/ColorTool.tsx b/frontend/src/components/modals/subtitle-tools/ColorTool.tsx
new file mode 100644
index 000000000..b5ae20acc
--- /dev/null
+++ b/frontend/src/components/modals/subtitle-tools/ColorTool.tsx
@@ -0,0 +1,36 @@
+import { Selector } from "@/components";
+import { useModal, withModal } from "@/modules/modals";
+import { submodProcessColor } from "@/utilities";
+import { FunctionComponent, useCallback, useState } from "react";
+import { Button } from "react-bootstrap";
+import { colorOptions } from "../toolOptions";
+import { useProcess } from "./ToolContext";
+
+const ColorTool: FunctionComponent = () => {
+ const [selection, setSelection] = useState<Nullable<string>>(null);
+
+ const Modal = useModal();
+
+ const process = useProcess();
+
+ const submit = useCallback(() => {
+ if (selection) {
+ const action = submodProcessColor(selection);
+ process(action);
+ }
+ }, [process, selection]);
+
+ const footer = (
+ <Button disabled={selection === null} onClick={submit}>
+ Save
+ </Button>
+ );
+
+ return (
+ <Modal title="Choose Color" footer={footer}>
+ <Selector options={colorOptions} onChange={setSelection}></Selector>
+ </Modal>
+ );
+};
+
+export default withModal(ColorTool, "color-tool");
diff --git a/frontend/src/components/modals/subtitle-tools/FrameRateTool.tsx b/frontend/src/components/modals/subtitle-tools/FrameRateTool.tsx
new file mode 100644
index 000000000..4c72e4d1b
--- /dev/null
+++ b/frontend/src/components/modals/subtitle-tools/FrameRateTool.tsx
@@ -0,0 +1,65 @@
+import { useModal, withModal } from "@/modules/modals";
+import { FunctionComponent, useCallback, useState } from "react";
+import { Button, Form, InputGroup } from "react-bootstrap";
+import { useProcess } from "./ToolContext";
+
+function submodProcessFrameRate(from: number, to: number) {
+ return `change_FPS(from=${from},to=${to})`;
+}
+
+const FrameRateTool: FunctionComponent = () => {
+ const [from, setFrom] = useState<Nullable<number>>(null);
+ const [to, setTo] = useState<Nullable<number>>(null);
+
+ const canSave = from !== null && to !== null && from !== to;
+
+ const Modal = useModal();
+
+ const process = useProcess();
+
+ const submit = useCallback(() => {
+ if (canSave) {
+ const action = submodProcessFrameRate(from, to);
+ process(action);
+ }
+ }, [canSave, from, process, to]);
+
+ const footer = (
+ <Button disabled={!canSave} onClick={submit}>
+ Save
+ </Button>
+ );
+
+ return (
+ <Modal title="Change Frame Rate" footer={footer}>
+ <InputGroup className="px-2">
+ <Form.Control
+ placeholder="From"
+ type="number"
+ onChange={(e) => {
+ const value = parseFloat(e.currentTarget.value);
+ if (isNaN(value)) {
+ setFrom(null);
+ } else {
+ setFrom(value);
+ }
+ }}
+ ></Form.Control>
+ <Form.Control
+ placeholder="To"
+ type="number"
+ onChange={(e) => {
+ const value = parseFloat(e.currentTarget.value);
+ if (isNaN(value)) {
+ setTo(null);
+ } else {
+ setTo(value);
+ }
+ }}
+ ></Form.Control>
+ </InputGroup>
+ </Modal>
+ );
+};
+
+export default withModal(FrameRateTool, "frame-rate-tool");
diff --git a/frontend/src/components/modals/subtitle-tools/TimeTool.tsx b/frontend/src/components/modals/subtitle-tools/TimeTool.tsx
new file mode 100644
index 000000000..6cbf62f4a
--- /dev/null
+++ b/frontend/src/components/modals/subtitle-tools/TimeTool.tsx
@@ -0,0 +1,100 @@
+import { useModal, withModal } from "@/modules/modals";
+import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ ChangeEventHandler,
+ FunctionComponent,
+ useCallback,
+ useState,
+} from "react";
+import { Button, Form, InputGroup } from "react-bootstrap";
+import { useProcess } from "./ToolContext";
+
+function submodProcessOffset(h: number, m: number, s: number, ms: number) {
+ return `shift_offset(h=${h},m=${m},s=${s},ms=${ms})`;
+}
+
+const TimeAdjustmentTool: FunctionComponent = () => {
+ const [isPlus, setPlus] = useState(true);
+ const [offset, setOffset] = useState<[number, number, number, number]>([
+ 0, 0, 0, 0,
+ ]);
+
+ const Modal = useModal();
+
+ const updateOffset = useCallback(
+ (idx: number): ChangeEventHandler<HTMLInputElement> => {
+ return (e) => {
+ let value = parseFloat(e.currentTarget.value);
+ if (isNaN(value)) {
+ value = 0;
+ }
+ const newOffset = [...offset] as [number, number, number, number];
+ newOffset[idx] = value;
+ setOffset(newOffset);
+ };
+ },
+ [offset]
+ );
+
+ const canSave = offset.some((v) => v !== 0);
+
+ const process = useProcess();
+
+ const submit = useCallback(() => {
+ if (canSave) {
+ const newOffset = offset.map((v) => (isPlus ? v : -v));
+ const action = submodProcessOffset(
+ newOffset[0],
+ newOffset[1],
+ newOffset[2],
+ newOffset[3]
+ );
+ process(action);
+ }
+ }, [canSave, offset, process, isPlus]);
+
+ const footer = (
+ <Button disabled={!canSave} onClick={submit}>
+ Save
+ </Button>
+ );
+
+ return (
+ <Modal title="Adjust Times" footer={footer}>
+ <InputGroup>
+ <InputGroup.Prepend>
+ <Button
+ variant="secondary"
+ title={isPlus ? "Later" : "Earlier"}
+ onClick={() => setPlus(!isPlus)}
+ >
+ <FontAwesomeIcon icon={isPlus ? faPlus : faMinus}></FontAwesomeIcon>
+ </Button>
+ </InputGroup.Prepend>
+ <Form.Control
+ type="number"
+ placeholder="hour"
+ onChange={updateOffset(0)}
+ ></Form.Control>
+ <Form.Control
+ type="number"
+ placeholder="min"
+ onChange={updateOffset(1)}
+ ></Form.Control>
+ <Form.Control
+ type="number"
+ placeholder="sec"
+ onChange={updateOffset(2)}
+ ></Form.Control>
+ <Form.Control
+ type="number"
+ placeholder="ms"
+ onChange={updateOffset(3)}
+ ></Form.Control>
+ </InputGroup>
+ </Modal>
+ );
+};
+
+export default withModal(TimeAdjustmentTool, "time-adjustment");
diff --git a/frontend/src/components/modals/subtitle-tools/ToolContext.ts b/frontend/src/components/modals/subtitle-tools/ToolContext.ts
new file mode 100644
index 000000000..5f1aecaa7
--- /dev/null
+++ b/frontend/src/components/modals/subtitle-tools/ToolContext.ts
@@ -0,0 +1,14 @@
+import { createContext, useContext } from "react";
+
+export type ProcessSubtitleType = (
+ action: string,
+ override?: Partial<FormType.ModifySubtitle>
+) => void;
+
+export const ProcessSubtitleContext = createContext<ProcessSubtitleType>(() => {
+ throw new Error("ProcessSubtitleContext not initialized");
+});
+
+export function useProcess() {
+ return useContext(ProcessSubtitleContext);
+}
diff --git a/frontend/src/components/modals/subtitle-tools/Translation.tsx b/frontend/src/components/modals/subtitle-tools/Translation.tsx
new file mode 100644
index 000000000..5f87c3121
--- /dev/null
+++ b/frontend/src/components/modals/subtitle-tools/Translation.tsx
@@ -0,0 +1,48 @@
+import { LanguageSelector } from "@/components/LanguageSelector";
+import { useModal, withModal } from "@/modules/modals";
+import { useEnabledLanguages } from "@/utilities/languages";
+import { FunctionComponent, useCallback, useMemo, useState } from "react";
+import { Button, Form } from "react-bootstrap";
+import { availableTranslation } from "../toolOptions";
+import { useProcess } from "./ToolContext";
+
+const TranslationTool: FunctionComponent = () => {
+ const { data: languages } = useEnabledLanguages();
+
+ const available = useMemo(
+ () => languages.filter((v) => v.code2 in availableTranslation),
+ [languages]
+ );
+
+ const Modal = useModal();
+
+ const [selectedLanguage, setLanguage] =
+ useState<Nullable<Language.Info>>(null);
+
+ const process = useProcess();
+
+ const submit = useCallback(() => {
+ if (selectedLanguage) {
+ process("translate", { language: selectedLanguage.code2 });
+ }
+ }, [process, selectedLanguage]);
+
+ const footer = (
+ <Button disabled={!selectedLanguage} onClick={submit}>
+ Translate
+ </Button>
+ );
+ return (
+ <Modal title="Translation" footer={footer}>
+ <Form.Label>
+ Enabled languages not listed here are unsupported by Google Translate.
+ </Form.Label>
+ <LanguageSelector
+ options={available}
+ onChange={setLanguage}
+ ></LanguageSelector>
+ </Modal>
+ );
+};
+
+export default withModal(TranslationTool, "translation-tool");
diff --git a/frontend/src/components/modals/subtitle-tools/index.tsx b/frontend/src/components/modals/subtitle-tools/index.tsx
new file mode 100644
index 000000000..e46cb519e
--- /dev/null
+++ b/frontend/src/components/modals/subtitle-tools/index.tsx
@@ -0,0 +1,230 @@
+import { useSubtitleAction } from "@/apis/hooks";
+import Language from "@/components/bazarr/Language";
+import { ActionButton, ActionButtonItem } from "@/components/buttons";
+import { SimpleTable } from "@/components/tables";
+import { useCustomSelection } from "@/components/tables/plugins";
+import {
+ useModal,
+ useModalControl,
+ usePayload,
+ withModal,
+} from "@/modules/modals";
+import { createTask, dispatchTask } from "@/modules/task/utilities";
+import { isMovie } from "@/utilities";
+import { LOG } from "@/utilities/console";
+import { isObject } from "lodash";
+import { FunctionComponent, useCallback, useMemo, useState } from "react";
+import { Badge, ButtonGroup, Dropdown } from "react-bootstrap";
+import { Column, useRowSelect } from "react-table";
+import {
+ ProcessSubtitleContext,
+ ProcessSubtitleType,
+ useProcess,
+} from "./ToolContext";
+import { tools } from "./tools";
+import { ToolOptions } from "./types";
+
+type SupportType = Item.Episode | Item.Movie;
+
+type TableColumnType = FormType.ModifySubtitle & {
+ raw_language: Language.Info;
+};
+
+function getIdAndType(item: SupportType): [number, "episode" | "movie"] {
+ if (isMovie(item)) {
+ return [item.radarrId, "movie"];
+ } else {
+ return [item.sonarrEpisodeId, "episode"];
+ }
+}
+
+const CanSelectSubtitle = (item: TableColumnType) => {
+ return item.path.endsWith(".srt");
+};
+
+function isElement(value: unknown): value is JSX.Element {
+ return isObject(value);
+}
+
+interface SubtitleToolViewProps {
+ count: number;
+ tools: ToolOptions[];
+ select: (items: TableColumnType[]) => void;
+}
+
+const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({
+ tools,
+ count,
+ select,
+}) => {
+ const payload = usePayload<SupportType[]>();
+
+ const Modal = useModal({
+ size: "lg",
+ });
+ const { show } = useModalControl();
+
+ const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>(
+ () => [
+ {
+ Header: "Language",
+ accessor: "raw_language",
+ Cell: ({ value }) => (
+ <Badge variant="secondary">
+ <Language.Text value={value} long></Language.Text>
+ </Badge>
+ ),
+ },
+ {
+ id: "file",
+ Header: "File",
+ accessor: "path",
+ Cell: ({ value }) => {
+ const path = value;
+
+ let idx = path.lastIndexOf("/");
+
+ if (idx === -1) {
+ idx = path.lastIndexOf("\\");
+ }
+
+ if (idx !== -1) {
+ return path.slice(idx + 1);
+ } else {
+ return path;
+ }
+ },
+ },
+ ],
+ []
+ );
+
+ const data = useMemo<TableColumnType[]>(
+ () =>
+ payload?.flatMap((item) => {
+ const [id, type] = getIdAndType(item);
+ return item.subtitles.flatMap((v) => {
+ if (v.path !== null) {
+ return [
+ {
+ id,
+ type,
+ language: v.code2,
+ path: v.path,
+ raw_language: v,
+ },
+ ];
+ } else {
+ return [];
+ }
+ });
+ }) ?? [],
+ [payload]
+ );
+
+ const plugins = [useRowSelect, useCustomSelection];
+
+ const process = useProcess();
+
+ const footer = useMemo(() => {
+ const action = tools[0];
+ const others = tools.slice(1);
+
+ return (
+ <Dropdown as={ButtonGroup} onSelect={(k) => k && process(k)}>
+ <ActionButton
+ size="sm"
+ disabled={count === 0}
+ icon={action.icon}
+ onClick={() => process(action.key)}
+ >
+ {action.name}
+ </ActionButton>
+ <Dropdown.Toggle
+ disabled={count === 0}
+ split
+ variant="light"
+ size="sm"
+ className="px-2"
+ ></Dropdown.Toggle>
+ <Dropdown.Menu>
+ {others.map((v) => (
+ <Dropdown.Item
+ key={v.key}
+ eventKey={v.modal ? undefined : v.key}
+ onSelect={() => {
+ if (v.modal) {
+ show(v.modal);
+ }
+ }}
+ >
+ <ActionButtonItem icon={v.icon}>{v.name}</ActionButtonItem>
+ </Dropdown.Item>
+ ))}
+ </Dropdown.Menu>
+ </Dropdown>
+ );
+ }, [count, process, show, tools]);
+
+ return (
+ <Modal title="Subtitle Tools" footer={footer}>
+ <SimpleTable
+ emptyText="No External Subtitles Found"
+ plugins={plugins}
+ columns={columns}
+ onSelect={select}
+ canSelect={CanSelectSubtitle}
+ data={data}
+ ></SimpleTable>
+ </Modal>
+ );
+};
+
+export const SubtitleToolModal = withModal(SubtitleToolView, "subtitle-tools");
+
+const SubtitleTools: FunctionComponent = () => {
+ const modals = useMemo(
+ () =>
+ tools
+ .map((t) => t.modal && <t.modal key={t.key}></t.modal>)
+ .filter(isElement),
+ []
+ );
+
+ const { hide } = useModalControl();
+ const [selections, setSelections] = useState<TableColumnType[]>([]);
+ const { mutateAsync } = useSubtitleAction();
+
+ const process = useCallback<ProcessSubtitleType>(
+ (action, override) => {
+ LOG("info", "executing action", action);
+ hide(SubtitleToolModal.modalKey);
+ const tasks = selections.map((s) => {
+ const form: FormType.ModifySubtitle = {
+ id: s.id,
+ type: s.type,
+ language: s.language,
+ path: s.path,
+ ...override,
+ };
+ return createTask(s.path, mutateAsync, { action, form });
+ });
+
+ dispatchTask(tasks, "modify-subtitles");
+ },
+ [hide, selections, mutateAsync]
+ );
+
+ return (
+ <ProcessSubtitleContext.Provider value={process}>
+ <SubtitleToolModal
+ count={selections.length}
+ tools={tools}
+ select={setSelections}
+ ></SubtitleToolModal>
+ {modals}
+ </ProcessSubtitleContext.Provider>
+ );
+};
+
+export default SubtitleTools;
diff --git a/frontend/src/components/modals/subtitle-tools/tools.ts b/frontend/src/components/modals/subtitle-tools/tools.ts
new file mode 100644
index 000000000..4310e9791
--- /dev/null
+++ b/frontend/src/components/modals/subtitle-tools/tools.ts
@@ -0,0 +1,80 @@
+import {
+ faClock,
+ faCode,
+ faDeaf,
+ faExchangeAlt,
+ faFilm,
+ faImage,
+ faLanguage,
+ faMagic,
+ faPaintBrush,
+ faPlay,
+ faTextHeight,
+} from "@fortawesome/free-solid-svg-icons";
+import ColorTool from "./ColorTool";
+import FrameRateTool from "./FrameRateTool";
+import TimeTool from "./TimeTool";
+import Translation from "./Translation";
+import { ToolOptions } from "./types";
+
+export const tools: ToolOptions[] = [
+ {
+ key: "sync",
+ icon: faPlay,
+ name: "Sync",
+ },
+ {
+ key: "remove_HI",
+ icon: faDeaf,
+ name: "Remove HI Tags",
+ },
+ {
+ key: "remove_tags",
+ icon: faCode,
+ name: "Remove Style Tags",
+ },
+ {
+ key: "OCR_fixes",
+ icon: faImage,
+ name: "OCR Fixes",
+ },
+ {
+ key: "common",
+ icon: faMagic,
+ name: "Common Fixes",
+ },
+ {
+ key: "fix_uppercase",
+ icon: faTextHeight,
+ name: "Fix Uppercase",
+ },
+ {
+ key: "reverse_rtl",
+ icon: faExchangeAlt,
+ name: "Reverse RTL",
+ },
+ {
+ key: "add_color",
+ icon: faPaintBrush,
+ name: "Add Color",
+ modal: ColorTool,
+ },
+ {
+ key: "change_frame_rate",
+ icon: faFilm,
+ name: "Change Frame Rate",
+ modal: FrameRateTool,
+ },
+ {
+ key: "adjust_time",
+ icon: faClock,
+ name: "Adjust Times",
+ modal: TimeTool,
+ },
+ {
+ key: "translation",
+ icon: faLanguage,
+ name: "Translate",
+ modal: Translation,
+ },
+];
diff --git a/frontend/src/components/modals/subtitle-tools/types.d.ts b/frontend/src/components/modals/subtitle-tools/types.d.ts
new file mode 100644
index 000000000..338ba2231
--- /dev/null
+++ b/frontend/src/components/modals/subtitle-tools/types.d.ts
@@ -0,0 +1,9 @@
+import { ModalComponent } from "@/modules/modals/WithModal";
+import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
+
+export interface ToolOptions {
+ key: string;
+ icon: IconDefinition;
+ name: string;
+ modal?: ModalComponent<unknown>;
+}
diff --git a/frontend/src/pages/Episodes/index.tsx b/frontend/src/pages/Episodes/index.tsx
index 9915a6293..f61bba3f0 100644
--- a/frontend/src/pages/Episodes/index.tsx
+++ b/frontend/src/pages/Episodes/index.tsx
@@ -7,11 +7,8 @@ import {
} from "@/apis/hooks";
import { ContentHeader, LoadingIndicator } from "@/components";
import ItemOverview from "@/components/ItemOverview";
-import {
- ItemEditorModal,
- SeriesUploadModal,
- SubtitleToolModal,
-} from "@/components/modals";
+import { ItemEditorModal, SeriesUploadModal } from "@/components/modals";
+import { SubtitleToolModal } from "@/components/modals/subtitle-tools";
import { useModalControl } from "@/modules/modals";
import { createAndDispatchTask } from "@/modules/task/utilities";
import { useLanguageProfileBy } from "@/utilities/languages";
diff --git a/frontend/src/pages/Episodes/table.tsx b/frontend/src/pages/Episodes/table.tsx
index 4a9326201..996755227 100644
--- a/frontend/src/pages/Episodes/table.tsx
+++ b/frontend/src/pages/Episodes/table.tsx
@@ -1,7 +1,10 @@
import { useDownloadEpisodeSubtitles, useEpisodesProvider } from "@/apis/hooks";
import { ActionButton, GroupTable, TextPopover } from "@/components";
-import { EpisodeHistoryModal, SubtitleToolModal } from "@/components/modals";
+import { EpisodeHistoryModal } from "@/components/modals";
import { EpisodeSearchModal } from "@/components/modals/ManualSearchModal";
+import SubtitleTools, {
+ SubtitleToolModal,
+} from "@/components/modals/subtitle-tools";
import { useModalControl } from "@/modules/modals";
import { useShowOnlyDesired } from "@/modules/redux/hooks";
import { BuildKey, filterSubtitleBy } from "@/utilities";
@@ -209,7 +212,7 @@ const Table: FunctionComponent<Props> = ({
}}
emptyText="No Episode Found For This Series"
></GroupTable>
- <SubtitleToolModal></SubtitleToolModal>
+ <SubtitleTools></SubtitleTools>
<EpisodeHistoryModal></EpisodeHistoryModal>
<EpisodeSearchModal
download={download}
diff --git a/frontend/src/pages/Movies/Details/index.tsx b/frontend/src/pages/Movies/Details/index.tsx
index 41efde6c5..ca0a56ee3 100644
--- a/frontend/src/pages/Movies/Details/index.tsx
+++ b/frontend/src/pages/Movies/Details/index.tsx
@@ -14,9 +14,11 @@ import {
ItemEditorModal,
MovieHistoryModal,
MovieUploadModal,
- SubtitleToolModal,
} from "@/components/modals";
import { MovieSearchModal } from "@/components/modals/ManualSearchModal";
+import SubtitleTools, {
+ SubtitleToolModal,
+} from "@/components/modals/subtitle-tools";
import { useModalControl } from "@/modules/modals";
import { createAndDispatchTask } from "@/modules/task/utilities";
import { useLanguageProfileBy } from "@/utilities/languages";
@@ -174,7 +176,7 @@ const MovieDetailView: FunctionComponent = () => {
<Table movie={movie} profile={profile} disabled={hasTask}></Table>
</Row>
<ItemEditorModal mutation={mutation}></ItemEditorModal>
- <SubtitleToolModal></SubtitleToolModal>
+ <SubtitleTools></SubtitleTools>
<MovieHistoryModal></MovieHistoryModal>
<MovieUploadModal></MovieUploadModal>
<MovieSearchModal