mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
implement translation task
This commit is contained in:
parent
f72c6214e8
commit
a3dd8512bd
5 changed files with 233 additions and 33 deletions
|
|
@ -32,6 +32,7 @@ export interface CaptionOptionProps {
|
|||
countryCode?: string;
|
||||
children: React.ReactNode;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onClick?: () => void;
|
||||
error?: React.ReactNode;
|
||||
|
|
@ -83,7 +84,7 @@ function CaptionOptionRightSide(props: CaptionOptionProps) {
|
|||
<div className="flex items-center">
|
||||
{translateBtn(true)}
|
||||
{props.error ? (
|
||||
<span className="text-video-context-error">
|
||||
<span className="flex items-center text-video-context-error">
|
||||
<Icon className="ml-2" icon={Icons.WARNING} />
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -166,6 +167,7 @@ export function CaptionOption(props: CaptionOptionProps) {
|
|||
selected={props.selected}
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
rightSide={<CaptionOptionRightSide {...props} />}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FlagIcon } from "@/components/FlagIcon";
|
||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
|
||||
|
||||
import { CaptionOption } from "./CaptionsView";
|
||||
|
|
@ -81,12 +83,59 @@ export function TranslateSubtitleView({
|
|||
}: LanguageSubtitlesViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useOverlayRouter(id);
|
||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const translateTask = usePlayerStore((s) => s.caption.translateTask);
|
||||
const translateCaption = usePlayerStore((s) => s.translateCaption);
|
||||
const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask);
|
||||
|
||||
useEffect(() => {
|
||||
if (!translateTask) {
|
||||
return;
|
||||
}
|
||||
if (translateTask.done) {
|
||||
console.log(translateTask.translatedCaption);
|
||||
// setCaption(translateTask.translatedCaption!);
|
||||
}
|
||||
}, [translateTask, setCaption]);
|
||||
|
||||
function renderTargetLang(langCode: string) {
|
||||
const friendlyName = getPrettyLanguageNameFromLocale(langCode);
|
||||
|
||||
async function onClick() {
|
||||
clearTranslateTask();
|
||||
await translateCaption(caption, langCode);
|
||||
}
|
||||
|
||||
return (
|
||||
<CaptionOption countryCode={langCode} flag>
|
||||
<CaptionOption
|
||||
key={langCode}
|
||||
countryCode={langCode}
|
||||
disabled={
|
||||
!!translateTask && !translateTask.done && !translateTask.error
|
||||
}
|
||||
loading={
|
||||
!!translateTask &&
|
||||
!translateTask.done &&
|
||||
!translateTask.error &&
|
||||
translateTask.targetLanguage === langCode
|
||||
}
|
||||
error={
|
||||
!!translateTask &&
|
||||
translateTask.error &&
|
||||
translateTask.targetLanguage === langCode
|
||||
}
|
||||
selected={
|
||||
!!translateTask &&
|
||||
translateTask.done &&
|
||||
translateTask.targetLanguage === langCode
|
||||
}
|
||||
onClick={() =>
|
||||
!translateTask || translateTask.done || translateTask.error
|
||||
? onClick()
|
||||
: undefined
|
||||
}
|
||||
flag
|
||||
>
|
||||
{friendlyName}
|
||||
</CaptionOption>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,13 +25,22 @@ const subtitlesTestSource: SourceSliceSource = {
|
|||
|
||||
const subtitlesTestSubs: CaptionListItem[] = [
|
||||
{
|
||||
id: "English",
|
||||
id: "http://localhost:8000/subs/en.srt",
|
||||
display: "English",
|
||||
language: "en",
|
||||
url: "http://localhost:8000/subs/en.srt",
|
||||
needsProxy: false,
|
||||
},
|
||||
{
|
||||
id: "Romanian",
|
||||
id: "http://localhost:8000/subs/en-small.srt",
|
||||
display: "English (small)",
|
||||
language: "en",
|
||||
url: "http://localhost:8000/subs/en-small.srt",
|
||||
needsProxy: false,
|
||||
},
|
||||
{
|
||||
id: "http://localhost:8000/subs/ro.srt",
|
||||
display: "Romanian",
|
||||
language: "ro",
|
||||
url: "http://localhost:8000/subs/ro.srt",
|
||||
needsProxy: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable no-console */
|
||||
import { ScrapeMedia } from "@p-stream/providers";
|
||||
|
||||
import { downloadCaption } from "@/backend/helpers/subs";
|
||||
import { MakeSlice } from "@/stores/player/slices/types";
|
||||
import {
|
||||
SourceQuality,
|
||||
|
|
@ -10,6 +11,8 @@ import {
|
|||
import { useQualityStore } from "@/stores/quality";
|
||||
import { ValuesOf } from "@/utils/typeguard";
|
||||
|
||||
import { translateSubtitle } from "../utils/captionstranslation";
|
||||
|
||||
export const playerStatus = {
|
||||
IDLE: "idle",
|
||||
RESUME: "resume",
|
||||
|
|
@ -73,6 +76,16 @@ export interface AudioTrack {
|
|||
language: string;
|
||||
}
|
||||
|
||||
export interface TranslateTask {
|
||||
targetCaption: CaptionListItem;
|
||||
fetchedTargetCaption?: Caption;
|
||||
targetLanguage: string;
|
||||
translatedCaption?: Caption;
|
||||
done: boolean;
|
||||
error: boolean;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export interface SourceSlice {
|
||||
status: PlayerStatus;
|
||||
source: SourceSliceSource | null;
|
||||
|
|
@ -87,6 +100,7 @@ export interface SourceSlice {
|
|||
caption: {
|
||||
selected: Caption | null;
|
||||
asTrack: boolean;
|
||||
translateTask: TranslateTask | null;
|
||||
};
|
||||
meta: PlayerMeta | null;
|
||||
failedSourcesPerMedia: Record<string, string[]>; // mediaKey -> array of failed sourceIds
|
||||
|
|
@ -106,6 +120,11 @@ export interface SourceSlice {
|
|||
redisplaySource(startAt: number): void;
|
||||
setCaptionAsTrack(asTrack: boolean): void;
|
||||
addExternalSubtitles(): Promise<void>;
|
||||
translateCaption(
|
||||
targetCaption: CaptionListItem,
|
||||
targetLanguage: string,
|
||||
): Promise<void>;
|
||||
clearTranslateTask(): void;
|
||||
addFailedSource(sourceId: string): void;
|
||||
addFailedEmbed(sourceId: string, embedId: string): void;
|
||||
clearFailedSources(mediaKey?: string): void;
|
||||
|
|
@ -174,6 +193,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||
caption: {
|
||||
selected: null,
|
||||
asTrack: false,
|
||||
translateTask: null,
|
||||
},
|
||||
setSourceId(id) {
|
||||
set((s) => {
|
||||
|
|
@ -374,9 +394,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||
s.meta = null;
|
||||
s.failedSourcesPerMedia = {};
|
||||
s.failedEmbedsPerMedia = {};
|
||||
this.clearTranslateTask();
|
||||
s.caption = {
|
||||
selected: null,
|
||||
asTrack: false,
|
||||
translateTask: null,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -413,4 +435,102 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearTranslateTask() {
|
||||
set((s) => {
|
||||
console.log("Clearing translate task");
|
||||
if (s.caption.translateTask) {
|
||||
console.log("Cancelling ongoing translate task");
|
||||
s.caption.translateTask.cancel();
|
||||
}
|
||||
s.caption.translateTask = null;
|
||||
});
|
||||
},
|
||||
|
||||
async translateCaption(
|
||||
targetCaption: CaptionListItem,
|
||||
targetLanguage: string,
|
||||
) {
|
||||
let store = get();
|
||||
|
||||
if (store.caption.translateTask) {
|
||||
console.warn("A translation task is already in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
set((s) => {
|
||||
s.caption.translateTask = {
|
||||
targetCaption,
|
||||
targetLanguage,
|
||||
done: false,
|
||||
error: false,
|
||||
cancel() {
|
||||
console.log("Translation task cancelled by user");
|
||||
cancelled = true;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function handleError(err: any) {
|
||||
console.error("Translation task ran into an error", err);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
set((s) => {
|
||||
if (!s.caption.translateTask) return;
|
||||
s.caption.translateTask.error = true;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const srtData = await downloadCaption(targetCaption);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!srtData) {
|
||||
throw new Error("Fetching failed");
|
||||
}
|
||||
set((s) => {
|
||||
if (!s.caption.translateTask) return;
|
||||
s.caption.translateTask.fetchedTargetCaption = {
|
||||
id: targetCaption.id,
|
||||
language: targetCaption.language,
|
||||
srtData,
|
||||
};
|
||||
});
|
||||
store = get();
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await translateSubtitle(
|
||||
targetCaption.id,
|
||||
store.caption.translateTask!.fetchedTargetCaption!.srtData,
|
||||
targetLanguage,
|
||||
);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
throw new Error("Translation failed");
|
||||
}
|
||||
set((s) => {
|
||||
if (!s.caption.translateTask) return;
|
||||
const translatedCaption: Caption = {
|
||||
id: `${targetCaption.id}-translated-${targetLanguage}`,
|
||||
language: targetLanguage,
|
||||
srtData: result,
|
||||
};
|
||||
s.caption.translateTask.done = true;
|
||||
s.caption.translateTask.translatedCaption = translatedCaption;
|
||||
console.log("Caption translation completed", s.caption.translateTask);
|
||||
});
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,14 +10,36 @@ const SUBTITLES_CACHE: Map<string, ArrayBuffer> = new Map<
|
|||
ArrayBuffer
|
||||
>();
|
||||
|
||||
async function translateText(text: string): Promise<string | undefined> {
|
||||
async function compressStr(string: string): Promise<ArrayBuffer> {
|
||||
const byteArray = new TextEncoder().encode(string);
|
||||
const cs = new CompressionStream("deflate");
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(byteArray);
|
||||
writer.close();
|
||||
return new Response(cs.readable).arrayBuffer();
|
||||
}
|
||||
|
||||
async function decompressStr(byteArray: ArrayBuffer): Promise<string> {
|
||||
const cs = new DecompressionStream("deflate");
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(byteArray);
|
||||
writer.close();
|
||||
return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => {
|
||||
return new TextDecoder().decode(arrayBuffer);
|
||||
});
|
||||
}
|
||||
|
||||
async function translateText(
|
||||
text: string,
|
||||
targetLang: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const response = await (
|
||||
await fetch(
|
||||
`${API_URL.replace("TARGET_LANG", "ro")}${encodeURIComponent(text)}`,
|
||||
`${API_URL.replace("{TARGET_LANG}", targetLang)}${encodeURIComponent(text)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
|
@ -36,12 +58,18 @@ async function translateText(text: string): Promise<string | undefined> {
|
|||
.join("");
|
||||
}
|
||||
|
||||
async function translateCaption(caption: ContentCaption): Promise<boolean> {
|
||||
async function translateCaption(
|
||||
caption: ContentCaption,
|
||||
targetLang: string,
|
||||
): Promise<boolean> {
|
||||
(caption as any).oldText = caption.text;
|
||||
let text: string | undefined;
|
||||
for (let i = 0; i < RETRY_COUNT; i += 1) {
|
||||
try {
|
||||
text = await translateText(caption.text.replace("\n", "<br>"));
|
||||
text = await translateText(
|
||||
caption.text.replace("\n", "<br>"),
|
||||
targetLang,
|
||||
);
|
||||
if (text) {
|
||||
text = text.replace("<br>", "\n");
|
||||
break;
|
||||
|
|
@ -59,11 +87,14 @@ async function translateCaption(caption: ContentCaption): Promise<boolean> {
|
|||
return true;
|
||||
}
|
||||
|
||||
async function translateCaptions(captions: ContentCaption[]): Promise<boolean> {
|
||||
async function translateCaptions(
|
||||
captions: ContentCaption[],
|
||||
targetLang: string,
|
||||
): Promise<boolean> {
|
||||
console.log("[CTR] Translating", captions.length, "captions");
|
||||
try {
|
||||
const results: boolean[] = await Promise.all(
|
||||
captions.map((c) => translateCaption(c)),
|
||||
captions.map((c) => translateCaption(c, targetLang)),
|
||||
);
|
||||
|
||||
const successCount = results.filter((v) => v).length;
|
||||
|
|
@ -106,7 +137,10 @@ function tryUseCached(
|
|||
return false;
|
||||
}
|
||||
|
||||
async function translateSRTData(data: string): Promise<string | undefined> {
|
||||
async function translateSRTData(
|
||||
data: string,
|
||||
targetLang: string,
|
||||
): Promise<string | undefined> {
|
||||
let captions: Caption[];
|
||||
try {
|
||||
captions = subsrt.parse(data);
|
||||
|
|
@ -149,7 +183,7 @@ async function translateSRTData(data: string): Promise<string | undefined> {
|
|||
}
|
||||
i += j;
|
||||
|
||||
if (!(await translateCaptions(batch))) {
|
||||
if (!(await translateCaptions(batch, targetLang))) {
|
||||
translatedCaptions = undefined;
|
||||
break;
|
||||
}
|
||||
|
|
@ -162,36 +196,22 @@ async function translateSRTData(data: string): Promise<string | undefined> {
|
|||
: undefined;
|
||||
}
|
||||
|
||||
async function compressStr(string: string): Promise<ArrayBuffer> {
|
||||
const byteArray = new TextEncoder().encode(string);
|
||||
const cs = new CompressionStream("deflate");
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(byteArray);
|
||||
writer.close();
|
||||
return new Response(cs.readable).arrayBuffer();
|
||||
}
|
||||
|
||||
async function decompressStr(byteArray: ArrayBuffer): Promise<string> {
|
||||
const cs = new DecompressionStream("deflate");
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(byteArray);
|
||||
writer.close();
|
||||
return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => {
|
||||
return new TextDecoder().decode(arrayBuffer);
|
||||
});
|
||||
}
|
||||
|
||||
export async function translateSubtitles(
|
||||
export async function translateSubtitle(
|
||||
id: string,
|
||||
srtData: string,
|
||||
targetLang: string,
|
||||
): Promise<string | undefined> {
|
||||
id = `${id}_${targetLang}`;
|
||||
const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(id);
|
||||
if (cachedData) {
|
||||
console.log("[CTR] Using cached translation for", id);
|
||||
return decompressStr(cachedData);
|
||||
}
|
||||
console.log("[CTR] Translating", id);
|
||||
const translatedData: string | undefined = await translateSRTData(srtData);
|
||||
const translatedData: string | undefined = await translateSRTData(
|
||||
srtData,
|
||||
targetLang,
|
||||
);
|
||||
if (!translatedData) {
|
||||
return undefined;
|
||||
}
|
||||
Loading…
Reference in a new issue