mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 00:52:27 +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;
|
countryCode?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
error?: React.ReactNode;
|
error?: React.ReactNode;
|
||||||
|
|
@ -83,7 +84,7 @@ function CaptionOptionRightSide(props: CaptionOptionProps) {
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{translateBtn(true)}
|
{translateBtn(true)}
|
||||||
{props.error ? (
|
{props.error ? (
|
||||||
<span className="text-video-context-error">
|
<span className="flex items-center text-video-context-error">
|
||||||
<Icon className="ml-2" icon={Icons.WARNING} />
|
<Icon className="ml-2" icon={Icons.WARNING} />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -166,6 +167,7 @@ export function CaptionOption(props: CaptionOptionProps) {
|
||||||
selected={props.selected}
|
selected={props.selected}
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
error={props.error}
|
error={props.error}
|
||||||
|
disabled={props.disabled}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
onDoubleClick={props.onDoubleClick}
|
onDoubleClick={props.onDoubleClick}
|
||||||
rightSide={<CaptionOptionRightSide {...props} />}
|
rightSide={<CaptionOptionRightSide {...props} />}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FlagIcon } from "@/components/FlagIcon";
|
import { FlagIcon } from "@/components/FlagIcon";
|
||||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
import { CaptionListItem } from "@/stores/player/slices/source";
|
||||||
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
|
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
|
||||||
|
|
||||||
import { CaptionOption } from "./CaptionsView";
|
import { CaptionOption } from "./CaptionsView";
|
||||||
|
|
@ -81,12 +83,59 @@ export function TranslateSubtitleView({
|
||||||
}: LanguageSubtitlesViewProps) {
|
}: LanguageSubtitlesViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useOverlayRouter(id);
|
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) {
|
function renderTargetLang(langCode: string) {
|
||||||
const friendlyName = getPrettyLanguageNameFromLocale(langCode);
|
const friendlyName = getPrettyLanguageNameFromLocale(langCode);
|
||||||
|
|
||||||
|
async function onClick() {
|
||||||
|
clearTranslateTask();
|
||||||
|
await translateCaption(caption, langCode);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{friendlyName}
|
||||||
</CaptionOption>
|
</CaptionOption>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,22 @@ const subtitlesTestSource: SourceSliceSource = {
|
||||||
|
|
||||||
const subtitlesTestSubs: CaptionListItem[] = [
|
const subtitlesTestSubs: CaptionListItem[] = [
|
||||||
{
|
{
|
||||||
id: "English",
|
id: "http://localhost:8000/subs/en.srt",
|
||||||
|
display: "English",
|
||||||
language: "en",
|
language: "en",
|
||||||
url: "http://localhost:8000/subs/en.srt",
|
url: "http://localhost:8000/subs/en.srt",
|
||||||
needsProxy: false,
|
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",
|
language: "ro",
|
||||||
url: "http://localhost:8000/subs/ro.srt",
|
url: "http://localhost:8000/subs/ro.srt",
|
||||||
needsProxy: false,
|
needsProxy: false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import { ScrapeMedia } from "@p-stream/providers";
|
import { ScrapeMedia } from "@p-stream/providers";
|
||||||
|
|
||||||
|
import { downloadCaption } from "@/backend/helpers/subs";
|
||||||
import { MakeSlice } from "@/stores/player/slices/types";
|
import { MakeSlice } from "@/stores/player/slices/types";
|
||||||
import {
|
import {
|
||||||
SourceQuality,
|
SourceQuality,
|
||||||
|
|
@ -10,6 +11,8 @@ import {
|
||||||
import { useQualityStore } from "@/stores/quality";
|
import { useQualityStore } from "@/stores/quality";
|
||||||
import { ValuesOf } from "@/utils/typeguard";
|
import { ValuesOf } from "@/utils/typeguard";
|
||||||
|
|
||||||
|
import { translateSubtitle } from "../utils/captionstranslation";
|
||||||
|
|
||||||
export const playerStatus = {
|
export const playerStatus = {
|
||||||
IDLE: "idle",
|
IDLE: "idle",
|
||||||
RESUME: "resume",
|
RESUME: "resume",
|
||||||
|
|
@ -73,6 +76,16 @@ export interface AudioTrack {
|
||||||
language: string;
|
language: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TranslateTask {
|
||||||
|
targetCaption: CaptionListItem;
|
||||||
|
fetchedTargetCaption?: Caption;
|
||||||
|
targetLanguage: string;
|
||||||
|
translatedCaption?: Caption;
|
||||||
|
done: boolean;
|
||||||
|
error: boolean;
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SourceSlice {
|
export interface SourceSlice {
|
||||||
status: PlayerStatus;
|
status: PlayerStatus;
|
||||||
source: SourceSliceSource | null;
|
source: SourceSliceSource | null;
|
||||||
|
|
@ -87,6 +100,7 @@ export interface SourceSlice {
|
||||||
caption: {
|
caption: {
|
||||||
selected: Caption | null;
|
selected: Caption | null;
|
||||||
asTrack: boolean;
|
asTrack: boolean;
|
||||||
|
translateTask: TranslateTask | null;
|
||||||
};
|
};
|
||||||
meta: PlayerMeta | null;
|
meta: PlayerMeta | null;
|
||||||
failedSourcesPerMedia: Record<string, string[]>; // mediaKey -> array of failed sourceIds
|
failedSourcesPerMedia: Record<string, string[]>; // mediaKey -> array of failed sourceIds
|
||||||
|
|
@ -106,6 +120,11 @@ export interface SourceSlice {
|
||||||
redisplaySource(startAt: number): void;
|
redisplaySource(startAt: number): void;
|
||||||
setCaptionAsTrack(asTrack: boolean): void;
|
setCaptionAsTrack(asTrack: boolean): void;
|
||||||
addExternalSubtitles(): Promise<void>;
|
addExternalSubtitles(): Promise<void>;
|
||||||
|
translateCaption(
|
||||||
|
targetCaption: CaptionListItem,
|
||||||
|
targetLanguage: string,
|
||||||
|
): Promise<void>;
|
||||||
|
clearTranslateTask(): void;
|
||||||
addFailedSource(sourceId: string): void;
|
addFailedSource(sourceId: string): void;
|
||||||
addFailedEmbed(sourceId: string, embedId: string): void;
|
addFailedEmbed(sourceId: string, embedId: string): void;
|
||||||
clearFailedSources(mediaKey?: string): void;
|
clearFailedSources(mediaKey?: string): void;
|
||||||
|
|
@ -174,6 +193,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
caption: {
|
caption: {
|
||||||
selected: null,
|
selected: null,
|
||||||
asTrack: false,
|
asTrack: false,
|
||||||
|
translateTask: null,
|
||||||
},
|
},
|
||||||
setSourceId(id) {
|
setSourceId(id) {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
|
|
@ -374,9 +394,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
s.meta = null;
|
s.meta = null;
|
||||||
s.failedSourcesPerMedia = {};
|
s.failedSourcesPerMedia = {};
|
||||||
s.failedEmbedsPerMedia = {};
|
s.failedEmbedsPerMedia = {};
|
||||||
|
this.clearTranslateTask();
|
||||||
s.caption = {
|
s.caption = {
|
||||||
selected: null,
|
selected: null,
|
||||||
asTrack: false,
|
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
|
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) {
|
if (!text) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await (
|
const response = await (
|
||||||
await fetch(
|
await fetch(
|
||||||
`${API_URL.replace("TARGET_LANG", "ro")}${encodeURIComponent(text)}`,
|
`${API_URL.replace("{TARGET_LANG}", targetLang)}${encodeURIComponent(text)}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -36,12 +58,18 @@ async function translateText(text: string): Promise<string | undefined> {
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function translateCaption(caption: ContentCaption): Promise<boolean> {
|
async function translateCaption(
|
||||||
|
caption: ContentCaption,
|
||||||
|
targetLang: string,
|
||||||
|
): Promise<boolean> {
|
||||||
(caption as any).oldText = caption.text;
|
(caption as any).oldText = caption.text;
|
||||||
let text: string | undefined;
|
let text: string | undefined;
|
||||||
for (let i = 0; i < RETRY_COUNT; i += 1) {
|
for (let i = 0; i < RETRY_COUNT; i += 1) {
|
||||||
try {
|
try {
|
||||||
text = await translateText(caption.text.replace("\n", "<br>"));
|
text = await translateText(
|
||||||
|
caption.text.replace("\n", "<br>"),
|
||||||
|
targetLang,
|
||||||
|
);
|
||||||
if (text) {
|
if (text) {
|
||||||
text = text.replace("<br>", "\n");
|
text = text.replace("<br>", "\n");
|
||||||
break;
|
break;
|
||||||
|
|
@ -59,11 +87,14 @@ async function translateCaption(caption: ContentCaption): Promise<boolean> {
|
||||||
return true;
|
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");
|
console.log("[CTR] Translating", captions.length, "captions");
|
||||||
try {
|
try {
|
||||||
const results: boolean[] = await Promise.all(
|
const results: boolean[] = await Promise.all(
|
||||||
captions.map((c) => translateCaption(c)),
|
captions.map((c) => translateCaption(c, targetLang)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const successCount = results.filter((v) => v).length;
|
const successCount = results.filter((v) => v).length;
|
||||||
|
|
@ -106,7 +137,10 @@ function tryUseCached(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function translateSRTData(data: string): Promise<string | undefined> {
|
async function translateSRTData(
|
||||||
|
data: string,
|
||||||
|
targetLang: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
let captions: Caption[];
|
let captions: Caption[];
|
||||||
try {
|
try {
|
||||||
captions = subsrt.parse(data);
|
captions = subsrt.parse(data);
|
||||||
|
|
@ -149,7 +183,7 @@ async function translateSRTData(data: string): Promise<string | undefined> {
|
||||||
}
|
}
|
||||||
i += j;
|
i += j;
|
||||||
|
|
||||||
if (!(await translateCaptions(batch))) {
|
if (!(await translateCaptions(batch, targetLang))) {
|
||||||
translatedCaptions = undefined;
|
translatedCaptions = undefined;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -162,36 +196,22 @@ async function translateSRTData(data: string): Promise<string | undefined> {
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compressStr(string: string): Promise<ArrayBuffer> {
|
export async function translateSubtitle(
|
||||||
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(
|
|
||||||
id: string,
|
id: string,
|
||||||
srtData: string,
|
srtData: string,
|
||||||
|
targetLang: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
|
id = `${id}_${targetLang}`;
|
||||||
const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(id);
|
const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(id);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
console.log("[CTR] Using cached translation for", id);
|
console.log("[CTR] Using cached translation for", id);
|
||||||
return decompressStr(cachedData);
|
return decompressStr(cachedData);
|
||||||
}
|
}
|
||||||
console.log("[CTR] Translating", id);
|
console.log("[CTR] Translating", id);
|
||||||
const translatedData: string | undefined = await translateSRTData(srtData);
|
const translatedData: string | undefined = await translateSRTData(
|
||||||
|
srtData,
|
||||||
|
targetLang,
|
||||||
|
);
|
||||||
if (!translatedData) {
|
if (!translatedData) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue