Support multi-language in subtitle upload modal

This commit is contained in:
LASER-Yi 2021-08-25 00:33:59 +08:00
parent e0b988b20f
commit 4a890b2561
4 changed files with 463 additions and 405 deletions

View File

@ -7,7 +7,7 @@ interface Props {
type RemovedSelectorProps<M extends boolean> = Omit<
SelectorProps<Language.Info, M>,
"label" | "placeholder"
"label"
>;
export type LanguageSelectorProps<M extends boolean> = Override<

View File

@ -1,27 +1,19 @@
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { Button, Container, Form } from "react-bootstrap";
import { Column, Row } from "react-table";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Form } from "react-bootstrap";
import { Column } from "react-table";
import { dispatchTask } from "../../@modules/task";
import { createTask } from "../../@modules/task/utilities";
import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
import { MoviesApi } from "../../apis";
import { BuildKey } from "../../utilities";
import { FileForm } from "../inputs";
import { LanguageSelector } from "../LanguageSelector";
import { SimpleTable } from "../tables";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
import SubtitleUploadModal, {
PendingSubtitle,
Validator,
} from "./SubtitleUploadModal";
interface PendingSubtitle {
file: File;
language: Language.Info;
interface Payload {
forced: boolean;
}
@ -30,178 +22,114 @@ export const TaskGroupName = "Uploading Subtitles...";
const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
const modal = props;
const { payload, closeModal } = useModalInformation<Item.Movie>(
modal.modalKey
);
const { payload } = useModalInformation<Item.Movie>(modal.modalKey);
const profile = useProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
const [pending, setPending] = useState<PendingSubtitle[]>([]);
const update = useCallback(async (list: PendingSubtitle<Payload>[]) => {
return list;
}, []);
const filelist = useMemo(() => pending.map((v) => v.file), [pending]);
const setFiles = useCallback(
(files: File[]) => {
const list: PendingSubtitle[] = files.map((v) => ({
file: v,
forced: availableLanguages[0].forced ?? false,
language: availableLanguages[0],
}));
setPending(list);
const validate = useCallback<Validator<Payload>>(
(item) => {
if (item.language === null) {
return {
state: "error",
messages: ["Language is not selected"],
};
} else if (
payload?.subtitles.find((v) => v.code2 === item.language?.code2) !==
undefined
) {
return {
state: "warning",
messages: ["Override existing subtitle"],
};
}
return {
state: "valid",
messages: [],
};
},
[availableLanguages]
[payload?.subtitles]
);
const upload = useCallback(() => {
if (payload === null || pending.length === 0) {
return;
}
const upload = useCallback(
(items: PendingSubtitle<Payload>[]) => {
if (payload === null) {
return;
}
const { radarrId } = payload;
const { radarrId } = payload;
const tasks = pending.map((v) => {
const { file, language, forced } = v;
const tasks = items
.filter((v) => v.language !== null)
.map((v) => {
const {
file,
language,
payload: { forced },
} = v;
return createTask(
file.name,
radarrId,
MoviesApi.uploadSubtitles.bind(MoviesApi),
radarrId,
{
file: file,
forced,
hi: false,
language: language.code2,
}
);
});
return createTask(
file.name,
radarrId,
MoviesApi.uploadSubtitles.bind(MoviesApi),
radarrId,
{
file: file,
forced,
hi: false,
language: language!.code2,
}
);
});
dispatchTask(TaskGroupName, tasks, "Uploading...");
setFiles([]);
closeModal();
}, [payload, closeModal, pending, setFiles]);
const modify = useCallback(
(row: Row<PendingSubtitle>, info?: PendingSubtitle) => {
setPending((pd) => {
const newPending = [...pd];
if (info) {
newPending[row.index] = info;
} else {
newPending.splice(row.index, 1);
}
return newPending;
});
dispatchTask(TaskGroupName, tasks, "Uploading...");
},
[]
[payload]
);
const columns = useMemo<Column<PendingSubtitle>[]>(
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
() => [
{
id: "state",
Cell: () => {
return "hello";
},
},
{
id: "name",
Header: "File",
accessor: (d) => d.file.name,
},
{
id: "forced",
Header: "Forced",
accessor: "forced",
accessor: "payload",
Cell: ({ row, value, update }) => {
const { original, index } = row;
return (
<Form.Check
custom
disabled={original.state === "fetching"}
id={BuildKey(index, original.file.name, "forced")}
checked={value}
checked={value.forced}
onChange={(v) => {
const newInfo = { ...row.original };
newInfo.forced = v.target.checked;
newInfo.payload.forced = v.target.checked;
update && update(row, newInfo);
}}
></Form.Check>
);
},
},
{
Header: "Language",
accessor: "language",
className: "w-25",
Cell: ({ row, update, value }) => {
return (
<LanguageSelector
options={availableLanguages}
value={value}
onChange={(lang) => {
if (lang && update) {
const newInfo = { ...row.original };
newInfo.language = lang;
update(row, newInfo);
}
}}
></LanguageSelector>
);
},
},
{
accessor: "file",
Cell: ({ row, update }) => {
return (
<Button
size="sm"
variant="light"
onClick={() => {
update && update(row);
}}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</Button>
);
},
},
],
[availableLanguages]
);
const canUpload = pending.length > 0;
const footer = (
<Button disabled={!canUpload} onClick={upload}>
Upload
</Button>
[]
);
return (
<BaseModal title={`Upload - ${payload?.title}`} footer={footer} {...modal}>
<Container fluid className="flex-column">
<Form>
<Form.Group>
<FileForm
emptyText="Select..."
disabled={canUpload || availableLanguages.length === 0}
multiple
value={filelist}
onChange={setFiles}
></FileForm>
</Form.Group>
</Form>
<div hidden={!canUpload}>
<SimpleTable
columns={columns}
data={pending}
responsive={false}
update={modify}
></SimpleTable>
</div>
</Container>
</BaseModal>
<SubtitleUploadModal
hideAllLanguages
initial={{ forced: false }}
availableLanguages={availableLanguages}
columns={columns}
upload={upload}
update={update}
validate={validate}
{...modal}
></SubtitleUploadModal>
);
};

View File

@ -1,213 +1,126 @@
import {
faCheck,
faCircleNotch,
faInfoCircle,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Button, Container, Form } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
import { FileForm, LanguageSelector, MessageIcon, SimpleTable } from "..";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
import { dispatchTask } from "../../@modules/task";
import { createTask } from "../../@modules/task/utilities";
import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
import { EpisodesApi, SubtitlesApi } from "../../apis";
import { Selector } from "../inputs";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
import SubtitleUploadModal, {
PendingSubtitle,
Validator,
} from "./SubtitleUploadModal";
interface PendingSubtitle {
file: File;
fetching: boolean;
instance?: Item.Episode;
interface Payload {
instance: Item.Episode | null;
}
type EpisodeMap = {
[name: string]: Item.Episode;
};
interface SerieProps {
interface SeriesProps {
episodes: readonly Item.Episode[];
}
export const TaskGroupName = "Uploading Subtitles...";
const SeriesUploadModal: FunctionComponent<SerieProps & BaseModalProps> = ({
const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
episodes,
...modal
}) => {
const { payload, closeModal } = useModalInformation<Item.Series>(
modal.modalKey
);
const [pending, setPending] = useState<PendingSubtitle[]>([]);
const { payload } = useModalInformation<Item.Series>(modal.modalKey);
const profile = useProfileBy(payload?.profileId);
const avaliableLanguages = useProfileItemsToLanguages(profile);
const availableLanguages = useProfileItemsToLanguages(profile);
const [language, setLanguage] = useState<Language.Info | null>(null);
useEffect(() => {
if (avaliableLanguages.length > 0) {
setLanguage(avaliableLanguages[0]);
}
}, [avaliableLanguages]);
const filelist = useMemo(() => pending.map((v) => v.file), [pending]);
const checkEpisodes = useCallback(
async (list: PendingSubtitle[]) => {
const update = useCallback(
async (list: PendingSubtitle<Payload>[]) => {
const newList = [...list];
const names = list.map((v) => v.file.name);
if (names.length > 0) {
const results = await SubtitlesApi.info(names);
const episodeMap = results.reduce<EpisodeMap>((prev, curr) => {
const ep = episodes.find(
(v) => v.season === curr.season && v.episode === curr.episode
);
if (ep) {
prev[curr.filename] = ep;
// TODO: Optimization
newList.forEach((v) => {
const info = results.find((f) => f.filename === v.file.name);
if (info) {
v.payload.instance =
episodes.find(
(e) => e.season === info.season && e.episode === info.episode
) ?? null;
}
return prev;
}, {});
setPending((pd) =>
pd.map((v) => {
const instance = episodeMap[v.file.name];
return {
...v,
instance,
fetching: false,
};
})
);
});
}
return newList;
},
[episodes]
);
const setFiles = useCallback(
(files: File[]) => {
// At lease 1 language is required
const list: PendingSubtitle[] = files.map((f) => {
return {
file: f,
didCheck: false,
fetching: true,
};
});
setPending(list);
checkEpisodes(list);
},
[checkEpisodes]
);
const upload = useCallback(() => {
if (payload === null || language === null) {
return;
const validate = useCallback<Validator<Payload>>((item) => {
const { language } = item;
const { instance } = item.payload;
if (language === null || instance === null) {
return {
state: "error",
messages: ["Language or Episode is not selected"],
};
} else if (
instance.subtitles.find((v) => v.code2 === language.code2) !== undefined
) {
return {
state: "warning",
messages: ["Override existing subtitle"],
};
}
return {
state: "valid",
messages: [],
};
}, []);
const { sonarrSeriesId: seriesid } = payload;
const { code2, hi, forced } = language;
const upload = useCallback(
(items: PendingSubtitle<Payload>[]) => {
if (payload === null) {
return;
}
const tasks = pending
.filter((v) => v.instance !== undefined)
.map((v) => {
const { sonarrEpisodeId: episodeid } = v.instance!;
const { sonarrSeriesId: seriesid } = payload;
const form: FormType.UploadSubtitle = {
file: v.file,
language: code2,
hi: hi ?? false,
forced: forced ?? false,
};
const tasks = items
.filter((v) => v.payload.instance !== undefined)
.map((v) => {
const { code2, hi, forced } = v.language!;
const { sonarrEpisodeId: episodeid } = v.payload.instance!;
return createTask(
v.file.name,
episodeid,
EpisodesApi.uploadSubtitles.bind(EpisodesApi),
seriesid,
episodeid,
form
);
});
const form: FormType.UploadSubtitle = {
file: v.file,
language: code2,
hi: hi ?? false,
forced: forced ?? false,
};
dispatchTask(TaskGroupName, tasks, "Uploading subtitles...");
setFiles([]);
closeModal();
}, [payload, pending, language, closeModal, setFiles]);
return createTask(
v.file.name,
episodeid,
EpisodesApi.uploadSubtitles.bind(EpisodesApi),
seriesid,
episodeid,
form
);
});
const canUpload = useMemo(
() =>
pending.length > 0 &&
pending.every((v) => v.instance !== undefined) &&
language,
[pending, language]
dispatchTask(TaskGroupName, tasks, "Uploading subtitles...");
},
[payload]
);
const showTable = pending.length > 0;
const columns = useMemo<Column<PendingSubtitle>[]>(
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
() => [
{
id: "Icon",
accessor: "fetching",
className: "text-center",
Cell: ({ value: fetching, row: { original } }) => {
let icon = faCircleNotch;
let color: string | undefined = undefined;
let spin = false;
let msgs: string[] = [];
const override = useMemo(
() =>
original.instance?.subtitles.find(
(v) => v.code2 === language?.code2
) !== undefined,
[original.instance?.subtitles]
);
if (fetching) {
spin = true;
} else if (override) {
icon = faInfoCircle;
color = "var(--warning)";
msgs.push("Overwrite existing subtitle");
} else if (original.instance) {
icon = faCheck;
color = "var(--success)";
} else {
icon = faInfoCircle;
color = "var(--warning)";
msgs.push("Season or episode info is missing");
}
return (
<MessageIcon
messages={msgs}
color={color}
icon={icon}
spin={spin}
></MessageIcon>
);
},
},
{
Header: "File",
accessor: (d) => d.file.name,
},
{
id: "instance",
Header: "Episode",
accessor: "instance",
accessor: "payload",
className: "vw-1",
Cell: ({ value, row, update }) => {
const options = episodes.map<SelectorOption<Item.Episode>>((ep) => ({
@ -219,7 +132,7 @@ const SeriesUploadModal: FunctionComponent<SerieProps & BaseModalProps> = ({
(ep: Nullable<Item.Episode>) => {
if (ep) {
const newInfo = { ...row.original };
newInfo.instance = ep;
newInfo.payload.instance = ep;
update && update(row, newInfo);
}
},
@ -228,101 +141,28 @@ const SeriesUploadModal: FunctionComponent<SerieProps & BaseModalProps> = ({
return (
<Selector
disabled={row.original.state === "fetching"}
options={options}
value={value ?? null}
value={value.instance}
onChange={change}
></Selector>
);
},
},
{
accessor: "file",
Cell: ({ row, update }) => {
return (
<Button
size="sm"
variant="light"
onClick={() => {
update && update(row);
}}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</Button>
);
},
},
],
[language?.code2, episodes]
);
const updateItem = useCallback<TableUpdater<PendingSubtitle>>(
(row, info?: PendingSubtitle) => {
setPending((pd) => {
const newPending = [...pd];
if (info) {
newPending[row.index] = info;
} else {
newPending.splice(row.index, 1);
}
return newPending;
});
},
[]
);
const footer = (
<div className="d-flex flex-row flex-grow-1 justify-content-between">
<div className="w-25">
<LanguageSelector
options={avaliableLanguages}
value={language}
onChange={(l) => {
if (l) {
setLanguage(l);
}
}}
></LanguageSelector>
</div>
<div>
<Button
disabled={pending.length === 0}
variant="outline-secondary"
className="mr-2"
onClick={() => setFiles([])}
>
Clean
</Button>
<Button disabled={!canUpload} onClick={upload}>
Upload
</Button>
</div>
</div>
[episodes]
);
return (
<BaseModal size="lg" title="Upload Subtitles" footer={footer} {...modal}>
<Container fluid className="flex-column">
<Form>
<Form.Group>
<FileForm
emptyText="Select..."
disabled={showTable || avaliableLanguages.length === 0}
multiple
value={filelist}
onChange={setFiles}
></FileForm>
</Form.Group>
</Form>
<div hidden={!showTable}>
<SimpleTable
columns={columns}
data={pending}
responsive={false}
update={updateItem}
></SimpleTable>
</div>
</Container>
</BaseModal>
<SubtitleUploadModal
columns={columns}
initial={{ instance: null }}
availableLanguages={availableLanguages}
upload={upload}
update={update}
validate={validate}
{...modal}
></SubtitleUploadModal>
);
};

View File

@ -0,0 +1,290 @@
import {
faCheck,
faCircleNotch,
faInfoCircle,
faTimes,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Container, Form } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
import { LanguageSelector, MessageIcon } from "..";
import { FileForm } from "../inputs";
import { SimpleTable } from "../tables";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useCloseModal } from "./hooks";
export interface PendingSubtitle<P> {
file: File;
state: "valid" | "fetching" | "warning" | "error";
messages: string[];
language: Language.Info | null;
payload: P;
}
export type Validator<T> = (
item: PendingSubtitle<T>
) => Pick<PendingSubtitle<T>, "state" | "messages">;
interface Props<T> {
initial: T;
availableLanguages: Language.Info[];
upload: (items: PendingSubtitle<T>[]) => void;
update: (items: PendingSubtitle<T>[]) => Promise<PendingSubtitle<T>[]>;
validate: Validator<T>;
columns: Column<PendingSubtitle<T>>[];
hideAllLanguages?: boolean;
}
export default function SubtitleUploadModal<T>(
props: Props<T> & Omit<BaseModalProps, "footer" | "title" | "size">
) {
const {
initial,
columns,
upload,
update,
validate,
availableLanguages,
hideAllLanguages,
} = props;
const closeModal = useCloseModal();
const [pending, setPending] = useState<PendingSubtitle<T>[]>([]);
const fileList = useMemo(() => pending.map((v) => v.file), [pending]);
const initialRef = useRef(initial);
const setFiles = useCallback(
async (files: File[]) => {
const initialLanguage =
availableLanguages.length > 0 ? availableLanguages[0] : null;
let list = files.map<PendingSubtitle<T>>((file) => ({
file,
state: "fetching",
messages: [],
language: initialLanguage,
payload: { ...initialRef.current },
}));
if (update) {
setPending(list);
list = await update(list);
} else {
list = list.map<PendingSubtitle<T>>((v) => ({
...v,
state: "valid",
}));
}
list = list.map((v) => ({
...v,
...validate(v),
}));
setPending(list);
},
[update, validate, availableLanguages]
);
const modify = useCallback<TableUpdater<PendingSubtitle<T>>>(
(row, info?: PendingSubtitle<T>) => {
setPending((pd) => {
const newPending = [...pd];
if (info) {
info = { ...info, ...validate(info) };
newPending[row.index] = info;
} else {
newPending.splice(row.index, 1);
}
return newPending;
});
},
[validate]
);
useEffect(() => {
setPending((pd) => {
const newPd = pd.map((v) => {
if (v.state !== "fetching") {
return { ...v, ...validate(v) };
} else {
return v;
}
});
return newPd;
});
}, [validate]);
const columnsWithAction = useMemo<Column<PendingSubtitle<T>>[]>(
() => [
{
id: "icon",
accessor: "state",
className: "text-center",
Cell: ({ value, row }) => {
let icon = faCircleNotch;
let color: string | undefined = undefined;
let spin = false;
switch (value) {
case "fetching":
spin = true;
break;
case "warning":
icon = faInfoCircle;
color = "var(--warning)";
break;
case "valid":
icon = faCheck;
color = "var(--success)";
break;
default:
icon = faTimes;
color = "var(--danger)";
break;
}
const messages = row.original.messages;
return (
<MessageIcon
messages={messages}
color={color}
icon={icon}
spin={spin}
></MessageIcon>
);
},
},
{
Header: "File",
accessor: (d) => d.file.name,
},
...columns,
{
id: "language",
Header: "Language",
accessor: "language",
className: "w-25",
Cell: ({ row, update, value }) => {
return (
<LanguageSelector
disabled={row.original.state === "fetching"}
options={availableLanguages}
value={value}
onChange={(lang) => {
if (lang && update) {
const newInfo = { ...row.original };
newInfo.language = lang;
update(row, newInfo);
}
}}
></LanguageSelector>
);
},
},
{
id: "action",
accessor: "file",
Cell: ({ row, update }) => (
<Button
size="sm"
variant="light"
disabled={row.original.state === "fetching"}
onClick={() => {
update && update(row);
}}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</Button>
),
},
],
[columns, availableLanguages]
);
const showTable = pending.length > 0;
const canUpload = useMemo(
() =>
pending.length > 0 &&
pending.every((v) => v.state === "valid" || v.state === "warning"),
[pending]
);
const footer = (
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
<div>
<Button
hidden={!showTable}
variant="outline-secondary"
className="mr-2"
onClick={() => setFiles([])}
>
Clean
</Button>
<Button
disabled={!canUpload || !showTable}
onClick={() => {
upload(pending);
closeModal();
}}
>
Upload
</Button>
</div>
<div className="w-25" hidden={hideAllLanguages}>
<LanguageSelector
options={availableLanguages}
value={null}
disabled={!showTable}
onChange={(lang) => {
if (lang) {
setPending((pd) =>
pd
.map((v) => ({ ...v, language: lang }))
.map((v) => ({ ...v, ...validate(v) }))
);
}
}}
></LanguageSelector>
</div>
</div>
);
return (
<BaseModal
size={showTable ? "xl" : "lg"}
title="Upload Subtitles"
footer={footer}
{...props}
>
<Container fluid className="flex-column">
<Form>
<Form.Group>
<FileForm
disabled={showTable}
emptyText="Select..."
multiple
value={fileList}
onChange={setFiles}
></FileForm>
</Form.Group>
</Form>
<div hidden={!showTable}>
<SimpleTable
columns={columnsWithAction}
data={pending}
responsive={false}
update={modify}
></SimpleTable>
</div>
</Container>
</BaseModal>
);
}