UNPKG

@vot.js/core

Version:
518 lines (517 loc) 19.9 kB
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, }; } } }