@vot.js/core
Version:
core package
518 lines (517 loc) • 19.9 kB
JavaScript
import config from "@vot.js/shared/config";
import Logger from "@vot.js/shared/utils/logger";
import { StreamInterval } from "@vot.js/shared/protos";
import { getSecYaHeaders, getSignature, getUUID } from "@vot.js/shared/secure";
import { fetchWithTimeout, getTimestamp } from "@vot.js/shared/utils/utils";
import { YandexVOTProtobuf, YandexSessionProtobuf } from "./protobuf.js";
import { AudioDownloadType, VideoTranslationStatus } from "./types/yandex.js";
import { convertVOT } from "./utils/vot.js";
export class VOTJSError extends Error {
data;
constructor(message, data = undefined) {
super(message);
this.data = data;
this.name = "VOTJSError";
this.message = message;
}
}
export class MinimalClient {
host;
schema;
fetch;
fetchOpts;
sessions = {};
userAgent = config.userAgent;
headers = {
"User-Agent": this.userAgent,
Accept: "application/x-protobuf",
"Accept-Language": "en",
"Content-Type": "application/x-protobuf",
Pragma: "no-cache",
"Cache-Control": "no-cache",
};
hostSchemaRe = /(http(s)?):\/\//;
constructor({ host = config.host, fetchFn = fetchWithTimeout, fetchOpts = {}, headers = {}, } = {}) {
const schema = this.hostSchemaRe.exec(host)?.[1];
this.host = schema ? host.replace(`${schema}://`, "") : host;
this.schema = schema ?? "https";
this.fetch = fetchFn;
this.fetchOpts = fetchOpts;
this.headers = { ...this.headers, ...headers };
}
async request(path, body, headers = {}, method = "POST") {
const options = this.getOpts(new Blob([body]), headers, method);
try {
const res = await this.fetch(`${this.schema}://${this.host}${path}`, options);
const data = (await res.arrayBuffer());
return {
success: res.status === 200,
data,
};
}
catch (err) {
return {
success: false,
data: err?.message,
};
}
}
async requestJSON(path, body = null, headers = {}, method = "POST") {
const options = this.getOpts(body, {
"Content-Type": "application/json",
...headers,
}, method);
try {
const res = await this.fetch(`${this.schema}://${this.host}${path}`, options);
const data = (await res.json());
return {
success: res.status === 200,
data,
};
}
catch (err) {
return {
success: false,
data: err?.message,
};
}
}
getOpts(body, headers = {}, method = "POST") {
return {
method,
headers: {
...this.headers,
...headers,
},
body,
...this.fetchOpts,
};
}
async getSession(module) {
const timestamp = getTimestamp();
const session = this.sessions[module];
if (session && session.timestamp + session.expires > timestamp) {
return session;
}
const { secretKey, expires, uuid } = await this.createSession(module);
this.sessions[module] = {
secretKey,
expires,
timestamp,
uuid,
};
return this.sessions[module];
}
async createSession(module) {
const uuid = getUUID();
const body = YandexSessionProtobuf.encodeSessionRequest(uuid, module);
const res = await this.request("/session/create", body, {
"Vtrans-Signature": await getSignature(body),
});
if (!res.success) {
throw new VOTJSError("Failed to request create session", res);
}
const sessionResponse = YandexSessionProtobuf.decodeSessionResponse(res.data);
return {
...sessionResponse,
uuid,
};
}
}
export default class VOTClient extends MinimalClient {
hostVOT;
schemaVOT;
requestLang;
responseLang;
paths = {
videoTranslation: "/video-translation/translate",
videoTranslationFailAudio: "/video-translation/fail-audio-js",
videoTranslationAudio: "/video-translation/audio",
videoSubtitles: "/video-subtitles/get-subtitles",
streamPing: "/stream-translation/ping-stream",
streamTranslation: "/stream-translation/translate-stream",
};
isCustomLink(url) {
return !!(/\.(m3u8|m4(a|v)|mpd)/.exec(url) ??
/^https:\/\/cdn\.qstv\.on\.epicgames\.com/.exec(url));
}
headersVOT = {
"User-Agent": `vot.js/${config.version}`,
"Content-Type": "application/json",
Pragma: "no-cache",
"Cache-Control": "no-cache",
};
constructor({ host, hostVOT = config.hostVOT, fetchFn, fetchOpts, requestLang = "en", responseLang = "ru", headers, } = {}) {
super({
host,
fetchFn,
fetchOpts,
headers,
});
const schemaVOT = this.hostSchemaRe.exec(hostVOT)?.[1];
this.hostVOT = schemaVOT ? hostVOT.replace(`${schemaVOT}://`, "") : hostVOT;
this.schemaVOT = schemaVOT ?? "https";
this.requestLang = requestLang;
this.responseLang = responseLang;
}
async requestVOT(path, body, headers = {}) {
const options = this.getOpts(JSON.stringify(body), {
...this.headersVOT,
...headers,
});
try {
const res = await this.fetch(`${this.schemaVOT}://${this.hostVOT}${path}`, options);
const data = (await res.json());
return {
success: res.status === 200,
data,
};
}
catch (err) {
return {
success: false,
data: err?.message,
};
}
}
async translateVideoYAImpl({ videoData, requestLang = this.requestLang, responseLang = this.responseLang, translationHelp = null, headers = {}, extraOpts = {}, shouldSendFailedAudio = true, }) {
const { url, duration = config.defaultDuration } = videoData;
const session = await this.getSession("video-translation");
const body = YandexVOTProtobuf.encodeTranslationRequest(url, duration, requestLang, responseLang, translationHelp, extraOpts);
const path = this.paths.videoTranslation;
const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path);
const res = await this.request(path, body, {
...vtransHeaders,
...headers,
});
if (!res.success) {
throw new VOTJSError("Failed to request video translation", res);
}
const translationData = YandexVOTProtobuf.decodeTranslationResponse(res.data);
Logger.log("translateVideo", translationData);
const { status, translationId, } = translationData;
switch (status) {
case VideoTranslationStatus.FAILED:
throw new VOTJSError("Yandex couldn't translate video", translationData);
case VideoTranslationStatus.FINISHED:
case VideoTranslationStatus.PART_CONTENT:
if (!translationData.url) {
throw new VOTJSError("Audio link wasn't received from Yandex response", translationData);
}
return {
translationId,
translated: true,
url: translationData.url,
status,
remainingTime: translationData.remainingTime ?? -1,
};
case VideoTranslationStatus.WAITING:
case VideoTranslationStatus.LONG_WAITING:
return {
translationId,
translated: false,
status,
remainingTime: translationData.remainingTime,
};
case VideoTranslationStatus.AUDIO_REQUESTED:
if (url.startsWith("https://youtu.be/") && shouldSendFailedAudio) {
await this.requestVtransFailAudio(url);
await this.requestVtransAudio(url, translationData.translationId, {
audioFile: new Uint8Array(),
fileId: AudioDownloadType.WEB_API_GET_ALL_GENERATING_URLS_DATA_FROM_IFRAME,
});
return await this.translateVideoYAImpl({
videoData,
requestLang,
responseLang,
translationHelp,
headers,
shouldSendFailedAudio: false,
});
}
return {
translationId,
translated: false,
status,
remainingTime: translationData.remainingTime ?? -1,
};
default:
Logger.error("Unknown response", translationData);
throw new VOTJSError("Unknown response from Yandex", translationData);
}
}
async translateVideoVOTImpl({ url, videoId, service, requestLang = this.requestLang, responseLang = this.responseLang, headers = {}, }) {
const votData = convertVOT(service, videoId, url);
const res = await this.requestVOT(this.paths.videoTranslation, {
provider: "yandex",
service: votData.service,
video_id: votData.videoId,
from_lang: requestLang,
to_lang: responseLang,
raw_video: url,
}, {
"X-Use-Snake-Case": "Yes",
...headers,
});
if (!res.success) {
throw new VOTJSError("Failed to request video translation", res);
}
const translationData = res.data;
switch (translationData.status) {
case "failed":
throw new VOTJSError("Yandex couldn't translate video", translationData);
case "success":
if (!translationData.translated_url) {
throw new VOTJSError("Audio link wasn't received from VOT response", translationData);
}
return {
translationId: String(translationData.id),
translated: true,
url: translationData.translated_url,
status: 1,
remainingTime: -1,
};
case "waiting":
return {
translationId: "",
translated: false,
remainingTime: translationData.remaining_time,
status: 2,
message: translationData.message,
};
}
}
async requestVtransFailAudio(url) {
const res = await this.requestJSON(this.paths.videoTranslationFailAudio, JSON.stringify({
video_url: url,
}), undefined, "PUT");
if (!res.data || typeof res.data === "string" || res.data.status !== 1) {
throw new VOTJSError("Failed to request to fake video translation fail audio js", res);
}
return res;
}
async requestVtransAudio(url, translationId, audioBuffer, partialAudio, headers = {}) {
const session = await this.getSession("video-translation");
const body = YandexVOTProtobuf.encodeTranslationAudioRequest(url, translationId, audioBuffer, partialAudio);
const path = this.paths.videoTranslationAudio;
const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path);
const res = await this.request(path, body, {
...vtransHeaders,
...headers,
}, "PUT");
if (!res.success) {
throw new VOTJSError("Failed to request video translation audio", res);
}
return YandexVOTProtobuf.decodeTranslationAudioResponse(res.data);
}
async translateVideo({ videoData, requestLang = this.requestLang, responseLang = this.responseLang, translationHelp = null, headers = {}, extraOpts = {}, shouldSendFailedAudio = true, }) {
const { url, videoId, host } = videoData;
return this.isCustomLink(url)
? await this.translateVideoVOTImpl({
url,
videoId,
service: host,
requestLang,
responseLang,
headers,
})
: await this.translateVideoYAImpl({
videoData,
requestLang,
responseLang,
translationHelp,
headers,
extraOpts,
shouldSendFailedAudio,
});
}
async getSubtitlesYAImpl({ videoData, requestLang = this.requestLang, headers = {}, }) {
const { url } = videoData;
const session = await this.getSession("video-translation");
const body = YandexVOTProtobuf.encodeSubtitlesRequest(url, requestLang);
const path = this.paths.videoSubtitles;
const vsubsHeaders = await getSecYaHeaders("Vsubs", session, body, path);
const res = await this.request(path, body, {
...vsubsHeaders,
...headers,
});
if (!res.success) {
throw new VOTJSError("Failed to request video subtitles", res);
}
const subtitlesData = YandexVOTProtobuf.decodeSubtitlesResponse(res.data);
const subtitles = subtitlesData.subtitles.map((subtitle) => {
const { language, url, translatedLanguage, translatedUrl } = subtitle;
return {
language,
url,
translatedLanguage,
translatedUrl,
};
});
return {
waiting: subtitlesData.waiting,
subtitles,
};
}
async getSubtitlesVOTImpl({ url, videoId, service, headers = {}, }) {
const votData = convertVOT(service, videoId, url);
const res = await this.requestVOT(this.paths.videoSubtitles, {
provider: "yandex",
service: votData.service,
video_id: votData.videoId,
}, headers);
if (!res.success) {
throw new VOTJSError("Failed to request video subtitles", res);
}
const subtitlesData = res.data;
const subtitles = subtitlesData.reduce((result, subtitle) => {
if (!subtitle.lang_from) {
return result;
}
const originalSubtitle = subtitlesData.find((sub) => sub.lang === subtitle.lang_from);
if (!originalSubtitle) {
return result;
}
result.push({
language: originalSubtitle.lang,
url: originalSubtitle.subtitle_url,
translatedLanguage: subtitle.lang,
translatedUrl: subtitle.subtitle_url,
});
return result;
}, []);
return {
waiting: false,
subtitles,
};
}
async getSubtitles({ videoData, requestLang = this.requestLang, headers = {}, }) {
const { url, videoId, host } = videoData;
return this.isCustomLink(url)
? await this.getSubtitlesVOTImpl({
url,
videoId,
service: host,
headers,
})
: await this.getSubtitlesYAImpl({
videoData,
requestLang,
headers,
});
}
async pingStream({ pingId, headers = {} }) {
const session = await this.getSession("video-translation");
const body = YandexVOTProtobuf.encodeStreamPingRequest(pingId);
const path = this.paths.streamPing;
const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path);
const res = await this.request(path, body, {
...vtransHeaders,
...headers,
});
if (!res.success) {
throw new VOTJSError("Failed to request stream ping", res);
}
return true;
}
async translateStream({ videoData, requestLang = this.requestLang, responseLang = this.responseLang, headers = {}, }) {
const { url } = videoData;
if (this.isCustomLink(url)) {
throw new VOTJSError("Unsupported video URL for getting stream translation");
}
const session = await this.getSession("video-translation");
const body = YandexVOTProtobuf.encodeStreamRequest(url, requestLang, responseLang);
const path = this.paths.streamTranslation;
const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path);
const res = await this.request(path, body, {
...vtransHeaders,
...headers,
});
if (!res.success) {
throw new VOTJSError("Failed to request stream translation", res);
}
const translateResponse = YandexVOTProtobuf.decodeStreamResponse(res.data);
const interval = translateResponse.interval;
switch (interval) {
case StreamInterval.NO_CONNECTION:
case StreamInterval.TRANSLATING:
return {
translated: false,
interval,
message: interval === StreamInterval.NO_CONNECTION
? "streamNoConnectionToServer"
: "translationTakeFewMinutes",
};
case StreamInterval.STREAMING: {
return {
translated: true,
interval,
pingId: translateResponse.pingId,
result: translateResponse.translatedInfo,
};
}
default:
Logger.error("Unknown response", translateResponse);
throw new VOTJSError("Unknown response from Yandex", translateResponse);
}
}
}
export class VOTWorkerClient extends VOTClient {
constructor(opts = {}) {
opts.host = opts.host ?? config.hostWorker;
super(opts);
}
async request(path, body, headers = {}, method = "POST") {
const options = this.getOpts(JSON.stringify({
headers: {
...this.headers,
...headers,
},
body: Array.from(body),
}), {
"Content-Type": "application/json",
}, method);
try {
const res = await this.fetch(`${this.schema}://${this.host}${path}`, options);
const data = (await res.arrayBuffer());
return {
success: res.status === 200,
data,
};
}
catch (err) {
return {
success: false,
data: err?.message,
};
}
}
async requestJSON(path, body = null, headers = {}, method = "POST") {
const options = this.getOpts(JSON.stringify({
headers: {
...this.headers,
"Content-Type": "application/json",
Accept: "application/json",
...headers,
},
body,
}), {
Accept: "application/json",
"Content-Type": "application/json",
}, method);
try {
const res = await this.fetch(`${this.schema}://${this.host}${path}`, options);
const data = (await res.json());
return {
success: res.status === 200,
data,
};
}
catch (err) {
return {
success: false,
data: err?.message,
};
}
}
}