mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 08:42:20 +00:00
Prepare for captions translation
This commit is contained in:
parent
25139cc4cc
commit
5aea772477
4 changed files with 278 additions and 9 deletions
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
|
@ -5,7 +5,13 @@
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[jsonc]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[type.scriptreact]": {
|
||||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ importers:
|
||||||
version: 1.8.0
|
version: 1.8.0
|
||||||
'@p-stream/providers':
|
'@p-stream/providers':
|
||||||
specifier: github:p-stream/providers#production
|
specifier: github:p-stream/providers#production
|
||||||
version: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0
|
version: https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1
|
||||||
'@plasmohq/messaging':
|
'@plasmohq/messaging':
|
||||||
specifier: ^0.6.2
|
specifier: ^0.6.2
|
||||||
version: 0.6.2(react@18.3.1)
|
version: 0.6.2(react@18.3.1)
|
||||||
|
|
@ -1207,8 +1207,8 @@ packages:
|
||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
'@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0':
|
'@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1':
|
||||||
resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0}
|
resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1}
|
||||||
version: 3.2.0
|
version: 3.2.0
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
|
|
@ -5523,7 +5523,7 @@ snapshots:
|
||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
'@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0':
|
'@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1':
|
||||||
dependencies:
|
dependencies:
|
||||||
abort-controller: 3.0.0
|
abort-controller: 3.0.0
|
||||||
cheerio: 1.0.0-rc.12
|
cheerio: 1.0.0-rc.12
|
||||||
|
|
|
||||||
201
src/components/player/utils/captionstranslation.ts
Normal file
201
src/components/player/utils/captionstranslation.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import subsrt from "subsrt-ts";
|
||||||
|
import { Caption, ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||||
|
|
||||||
|
const API_URL =
|
||||||
|
"https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&ie=UTF-8&oe=UTF-8&sl=auto&tl={TARGET_LANG}&q=";
|
||||||
|
const RETRY_COUNT = 3;
|
||||||
|
const FETCH_RATE = 100;
|
||||||
|
const SUBTITLES_CACHE: Map<string, ArrayBuffer> = new Map<
|
||||||
|
string,
|
||||||
|
ArrayBuffer
|
||||||
|
>();
|
||||||
|
|
||||||
|
async function translateText(text: string): Promise<string | undefined> {
|
||||||
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await (
|
||||||
|
await fetch(
|
||||||
|
`${API_URL.replace("TARGET_LANG", "ro")}${encodeURIComponent(text)}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error("Empty response");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response.sentences as any[])
|
||||||
|
.map((s: any) => s.trans as string)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateCaption(caption: ContentCaption): 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>"));
|
||||||
|
if (text) {
|
||||||
|
text = text.replace("<br>", "\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[CTR] Re-trying caption translation", caption, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
console.error("[CTR] Failed to translate caption");
|
||||||
|
caption.text = `(CAPTION COULD NOT BE TRANSLATED)\n${caption.text}}`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
caption.text = text.trim();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateCaptions(captions: ContentCaption[]): Promise<boolean> {
|
||||||
|
console.log("[CTR] Translating", captions.length, "captions");
|
||||||
|
try {
|
||||||
|
const results: boolean[] = await Promise.all(
|
||||||
|
captions.map((c) => translateCaption(c)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const successCount = results.filter((v) => v).length;
|
||||||
|
const failedCount = results.length - successCount;
|
||||||
|
const successPercentange = (successCount / results.length) * 100;
|
||||||
|
const failedPercentange = (failedCount / results.length) * 100;
|
||||||
|
console.log(
|
||||||
|
"[CTR] Done translating captions",
|
||||||
|
results.length,
|
||||||
|
successCount,
|
||||||
|
failedCount,
|
||||||
|
successPercentange,
|
||||||
|
failedPercentange,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failedPercentange > successPercentange) {
|
||||||
|
throw new Error("Success percentage is not acceptable");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[CTR] Could not translate",
|
||||||
|
captions.length,
|
||||||
|
"captions",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryUseCached(
|
||||||
|
caption: ContentCaption,
|
||||||
|
cache: Map<string, string>,
|
||||||
|
): boolean {
|
||||||
|
const text: string | undefined = cache.get(caption.text);
|
||||||
|
if (text) {
|
||||||
|
caption.text = text;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateSRTData(data: string): Promise<string | undefined> {
|
||||||
|
let captions: Caption[];
|
||||||
|
try {
|
||||||
|
captions = subsrt.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CTR] Failed to parse subtitle data", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let translatedCaptions: Caption[] | undefined = [];
|
||||||
|
const contentCaptions: ContentCaption[] = [];
|
||||||
|
const translatedCache: Map<string, string> = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
translatedCaptions.push(caption);
|
||||||
|
if (caption.type !== "caption") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
caption.text = caption.text
|
||||||
|
.trim()
|
||||||
|
.replace("\r\n", "\n")
|
||||||
|
.replace("\r", "\n");
|
||||||
|
contentCaptions.push(caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < contentCaptions.length; i += 1) {
|
||||||
|
if (tryUseCached(contentCaptions[i], translatedCache)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const batch: ContentCaption[] = [contentCaptions[i]];
|
||||||
|
|
||||||
|
let j;
|
||||||
|
for (j = 1; j < FETCH_RATE; j += 1) {
|
||||||
|
if (i + j >= contentCaptions.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (tryUseCached(contentCaptions[i + j], translatedCache)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
batch.push(contentCaptions[i + j]);
|
||||||
|
}
|
||||||
|
i += j;
|
||||||
|
|
||||||
|
if (!(await translateCaptions(batch))) {
|
||||||
|
translatedCaptions = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.forEach((c) => translatedCache.set((c as any).oldText!, c.text));
|
||||||
|
}
|
||||||
|
|
||||||
|
return translatedCaptions
|
||||||
|
? subsrt.build(translatedCaptions, { format: "srt" })
|
||||||
|
: 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(function (arrayBuffer) {
|
||||||
|
return new TextDecoder().decode(arrayBuffer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function translateSubtitles(
|
||||||
|
id: string,
|
||||||
|
srtData: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
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);
|
||||||
|
if (!translatedData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
console.log("[CTR] Caching translation for", id);
|
||||||
|
SUBTITLES_CACHE.set(id, await compressStr(translatedData));
|
||||||
|
return translatedData;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,74 @@
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/buttons/Button";
|
import { Button } from "@/components/buttons/Button";
|
||||||
|
import { usePlayer } from "@/components/player/hooks/usePlayer";
|
||||||
|
import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart";
|
||||||
|
import { PlayerPart } from "@/pages/parts/player/PlayerPart";
|
||||||
|
import {
|
||||||
|
CaptionListItem,
|
||||||
|
PlayerMeta,
|
||||||
|
playerStatus,
|
||||||
|
} from "@/stores/player/slices/source";
|
||||||
|
import { SourceSliceSource } from "@/stores/player/utils/qualities";
|
||||||
|
|
||||||
|
const subtitlesTestMeta: PlayerMeta = {
|
||||||
|
type: "movie",
|
||||||
|
title: "Subtitles Test",
|
||||||
|
releaseYear: 2024,
|
||||||
|
tmdbId: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitlesTestSource: SourceSliceSource = {
|
||||||
|
type: "hls",
|
||||||
|
url: "http://localhost:8000/media/master.m3u8",
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitlesTestSubs: CaptionListItem[] = [
|
||||||
|
{
|
||||||
|
id: "English",
|
||||||
|
language: "en",
|
||||||
|
url: "http://localhost:8000/subs/en.srt",
|
||||||
|
needsProxy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Romanian",
|
||||||
|
language: "ro",
|
||||||
|
url: "http://localhost:8000/subs/ro.srt",
|
||||||
|
needsProxy: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// mostly empty view, add whatever you need
|
// mostly empty view, add whatever you need
|
||||||
export default function TestView() {
|
export default function TestView() {
|
||||||
const [val, setVal] = useState(false);
|
const player = usePlayer();
|
||||||
|
const [showPlayer, setShowPlayer] = useState(false);
|
||||||
|
const [shouldCrash, setShouldCrash] = useState(false);
|
||||||
|
|
||||||
if (val) throw new Error("I crashed");
|
if (shouldCrash) {
|
||||||
|
throw new Error("I crashed");
|
||||||
|
}
|
||||||
|
|
||||||
return <Button onClick={() => setVal(true)}>Crash me!</Button>;
|
const subtitlesTest = useCallback(async () => {
|
||||||
|
setShowPlayer(true);
|
||||||
|
player.reset();
|
||||||
|
await new Promise((r) => {
|
||||||
|
setTimeout(r, 100);
|
||||||
|
});
|
||||||
|
player.setShouldStartFromBeginning(true);
|
||||||
|
player.setMeta(subtitlesTestMeta);
|
||||||
|
player.playMedia(subtitlesTestSource, subtitlesTestSubs, null);
|
||||||
|
}, [player]);
|
||||||
|
|
||||||
|
return showPlayer ? (
|
||||||
|
<PlayerPart backUrl="/dev/">
|
||||||
|
{player && (player as any).status === playerStatus.PLAYBACK_ERROR ? (
|
||||||
|
<PlaybackErrorPart />
|
||||||
|
) : null}
|
||||||
|
</PlayerPart>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setShouldCrash(true)}>Crash me!</Button>
|
||||||
|
<Button onClick={() => subtitlesTest()}>Subtitles test</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue