Compare commits

...

52 commits

Author SHA1 Message Date
Toil
af99cad99e
build: added signed firefox ext
Some checks failed
Release / create_release (push) Has been cancelled
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
2026-10-08 01:23:56 +03:00
Toil
3379770a2b
refactor: cleanup after #1873 2026-10-08 01:20:33 +03:00
Toil
8659658b1e
Merge pull request #1872 from turboenotak/feat/dropout
Some checks are pending
Build / node (22.x) (push) Waiting to run
Build / extension (22.x) (push) Waiting to run
Build / bun (push) Waiting to run
Release / Build Release (push) Waiting to run
Release / create_release (push) Blocked by required conditions
feat: add dropout.tv support
2026-10-07 23:13:12 +03:00
Toil
0d51587b62
Merge pull request #1873 from Acobat12/youtube-sabr-clean
feat: use SABR for YouTube audio downloading with WebABR fallback
2026-10-07 23:12:49 +03:00
Acobat12
9e4797fb70 fix: invalid native YouTube player 2026-10-07 12:05:10 +02:00
Acobat12
9d65097a46 Fix adaptive volume in Safari2 2026-10-06 21:56:49 +02:00
Acobat12
d27ecd316a Fix adaptive volume in Safari 2026-10-06 21:47:04 +02:00
Acobat12
350c529013 Separate SABR and WebABR audio strategies 2026-10-06 17:42:19 +02:00
Acobat12
9cb7091150 Fix SABR and WebABR audio bridge across browsers 2026-10-06 13:35:09 +02:00
Acobat12
7fe59bc833 Fix Safari SABR and WebABR 2026-10-06 11:59:48 +02:00
Turbo Raccoon
9246bc03f7 fix: match dropout only in the vhx.tv player iframe
Videos on watch.dropout.tv are played in an embed.vhx.tv iframe, so the top page doesn't need the userscript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:29:46 +03:00
Acobat12
16348de4ac fix: support promise GM request API in SABR 2026-10-04 12:25:02 +02:00
Acobat12
e74ca75e8b fix: translate YouTube sign-in message 2026-10-04 09:13:46 +02:00
Acobat12
c9a502500a chore: update localization hashes 2026-10-04 06:24:10 +02:00
Acobat12
54f014a5ed feat: add YouTube SABR audio transport 2026-10-04 06:20:17 +02:00
Turbo Raccoon
988b568c90 chore: bump vot.js to 3.1.5 (dropout support)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:24:48 +03:00
Turbo Raccoon
c50a5556f4 feat: add dropout.tv support
Requires @vot.js/ext with the dropout helper (FOSWLY/vot.js).
2026-10-01 16:44:44 +03:00
Toil
afcb445354
fix: translation button reset error to none state on click
Some checks failed
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
Release / create_release (push) Has been cancelled
2026-09-25 22:23:42 +03:00
Toil
35e02fee95
build: added signed firefox 1.11.15
Some checks failed
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
Release / create_release (push) Has been cancelled
2026-09-24 18:38:45 +03:00
Toil
a584030429
Merge pull request #1850 from jatixs/hotfix/1.11.4
Some checks are pending
Build / node (22.x) (push) Waiting to run
Build / extension (22.x) (push) Waiting to run
Build / bun (push) Waiting to run
Release / Build Release (push) Waiting to run
Release / create_release (push) Blocked by required conditions
Fix: button display when ad‑blocking extensions are active
2026-09-23 23:53:16 +03:00
Toil
e37faf503a
feat: try to downloading youtube audio with anonymous mode in web_abr strategy 2026-09-23 23:07:29 +03:00
Toil
0aa3c4cc60
feat: try to downloading youtube audio with anonymous mode in web_abr strategy 2026-09-23 23:06:07 +03:00
Toil
b5c5e0748e
Merge pull request #1852 from ilyhalight/web-abr-update
Some checks failed
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
Release / create_release (push) Has been cancelled
Release 1.11.14
2026-09-21 23:53:30 +03:00
Toil
2e86c63f73
feat(#1846): improve Web ABR and audio retries 2026-09-21 23:51:31 +03:00
Jatix
345fefee30 fix: resolve button rendering failure caused by ad-blocking extensions 2026-09-21 22:12:47 +03:00
Jatix
e5657f4877 Revert "The display of buttons has been fixed when using ad‑blocking extensions."
This reverts commit 810e9cd48e.
2026-09-21 21:58:48 +03:00
Toil
105a6371a0
Merge pull request #1846 from Acobat12/web-abr-upstream-test
fix: resume audio upload after chunk failure
2026-09-21 15:16:50 +03:00
Acobat12
0938ce341d fix: preserve manually selected source language on YouTube 2026-09-21 04:41:45 +02:00
Acobat12
82c6290ec4 chore: restore extension version 2026-09-21 00:58:13 +02:00
Acobat12
d8125fc662 chore: restore generated extension archive 2026-09-21 00:48:31 +02:00
Acobat12
77fe9f2337 chore: restore extension update id 2026-09-21 00:43:26 +02:00
Acobat12
533a55f49a fix: update localization hashes 2026-09-21 00:35:56 +02:00
Acobat12
013f5b40a8 fix: handle unsupported auto-detected source language 2026-09-20 20:18:13 +02:00
Acobat12
2ba2576cba fix: localize retry translation message 2026-09-20 03:43:56 +02:00
Jatix
8b4e6cc0c1 Merge branch 'hotfix/1.11.4' of github.com:jatixs/voice-over-translation into hotfix/1.11.4 2026-09-20 02:58:39 +03:00
Jatix
34bdfa5b4c fix: resolve button rendering failure caused by ad-blocking extensions 2026-09-20 02:58:06 +03:00
Acobat12
c3e08dc707 fix: sync manually selected source language 2026-09-20 01:56:43 +02:00
Jatix
810e9cd48e The display of buttons has been fixed when using ad‑blocking extensions. 2026-09-20 02:51:12 +03:00
Acobat12
eec61ea9ae fix: address audio upload review feedback 2026-09-19 23:27:28 +02:00
Acobat12
606a9feb41 fix: resume audio upload after chunk failure 2026-09-19 05:08:09 +02:00
Toil
7a823f1143
feat!: migrate to VOTNextWorkerProvider
Some checks failed
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
Release / create_release (push) Has been cancelled
2026-09-17 23:32:30 +03:00
Toil
68952e4135
feat: improve WEB_ABR strategy with smart audio select 2026-09-17 23:19:45 +03:00
Toil
fa9789b4a9
fix(#1837): translating on netacad 2026-09-17 23:07:42 +03:00
Toil
c39bac2a30
test: removed unused files 2026-09-17 22:59:46 +03:00
Toil
7efa6d3bcd
refactor: simplified web_abr strategy
Some checks failed
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
Release / create_release (push) Has been cancelled
2026-09-11 11:48:49 +03:00
Toil
d41c3d7da0
feat: implement WEB_ABR strategy 2026-09-11 11:04:44 +03:00
Toil
72bac1c0a5
build: rebuild with correct chrome id
Some checks failed
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
Release / create_release (push) Has been cancelled
2026-09-08 22:56:41 +03:00
Toil
6c6b1398d1
chore: bump vot.js 2026-09-08 22:48:09 +03:00
Toil
f8972d3d40
fix: adaptive mseProxy for long videos, increase player wait to 30s, more debug logs 2026-09-08 22:13:22 +03:00
Toil
5bad9ef38f
Merge pull request #1824 from ilyhalight/fix/mse-proxy
Some checks failed
Build / node (22.x) (push) Has been cancelled
Build / extension (22.x) (push) Has been cancelled
Build / bun (push) Has been cancelled
Release / Build Release (push) Has been cancelled
Release / create_release (push) Has been cancelled
fix(#1822): double run audio download
2026-09-05 20:41:48 +03:00
Toil
5f2e9be297
Merge branch 'master' of https://github.com/ilyhalight/voice-over-translation into fix/mse-proxy 2026-09-05 20:39:58 +03:00
Toil
4815d55467
fix(#1822): double run audio download 2026-09-05 20:39:14 +03:00
116 changed files with 27321 additions and 1611 deletions

View file

@ -6,12 +6,15 @@
"name": "voice-over-translation",
"dependencies": {
"@toil/gm-types": "^1.0.5",
"@vot.js/core": "^3.0.2",
"@vot.js/ext": "^3.0.2",
"@vot.js/shared": "^3.0.2",
"@vot.js/core": "^3.1.5",
"@vot.js/ext": "^3.1.5",
"@vot.js/shared": "^3.1.5",
"astring": "1.9.0",
"bowser": "^2.14.1",
"chaimu": "^1.1.0",
"googlevideo": "^4.1.1",
"lit-html": "^3.3.3",
"meriyah": "6.1.4",
},
"devDependencies": {
"@biomejs/biome": "^2.5.6",
@ -110,11 +113,11 @@
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, ""],
"@vot.js/core": ["@vot.js/core@3.0.2", "", { "dependencies": { "@vot.js/shared": "^3.0.2" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-MzvV31J++VTIc+OWv/vXh52cPtKBnfuOfelI7O4kaEHCPtn36XAMqPyaI7CjidugB54kIfD9mBvLnJaBWYhtkA=="],
"@vot.js/core": ["@vot.js/core@3.1.5", "", { "dependencies": { "@vot.js/shared": "^3.1.5" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-CLrGkcyL/D12AVZX/vxRPEKpYYGWEVkGa3CSrFwfTb0N3IcFDp9jFpDqWjJHmSUiQgmsLV8btEqNOu1WhSGsBQ=="],
"@vot.js/ext": ["@vot.js/ext@3.0.2", "", { "dependencies": { "@vot.js/core": "^3.0.2", "@vot.js/shared": "^3.0.2" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-8S0s7ABCYSEszOUkVFoxDP07dew26z3XF6LMMdbDxEHwD2IfuKVhPJwa+0dG5dN2rcwb6AJW1lig8cgkl7AkNg=="],
"@vot.js/ext": ["@vot.js/ext@3.1.5", "", { "dependencies": { "@vot.js/core": "^3.1.5", "@vot.js/shared": "^3.1.5" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-Z/jLcJ64E6432dY+8JI3GtM36/KMM0zti7u8U5nX1gjfNwoXKpAEPGb9vLI/gkgArLiphlyMHh/QG21RaHafgA=="],
"@vot.js/shared": ["@vot.js/shared@3.0.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-FvAM7TDlNyUrGio+uu0FYRO8rkN++3gTtEqKRjG2gqBs5ceeGQYBTr5j83qxjsNp3HcwJp0qJiL867ehW5F46g=="],
"@vot.js/shared": ["@vot.js/shared@3.1.5", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" }, "peerDependencies": { "typescript": "^6.0.0" } }, "sha512-vOj4f0nB9oJ8ra5KGw39lDtRR2XFNZkz5gLTOTqqX2HtbuK4uEfZpq/iePlCjX5gnD+W60nQqVkvpSeEyjsU/A=="],
"@webcomponents/custom-elements": ["@webcomponents/custom-elements@1.6.0", "", {}, "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww=="],
@ -124,6 +127,8 @@
"ansi-styles": ["ansi-styles@7.0.0", "", {}, "sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
@ -168,6 +173,8 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"googlevideo": ["googlevideo@4.1.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-14o64O+CXXlQ1stYZK/+ypCEwZb25lzEuzhbFYYEzf26R+HhBDOR+Dkdk7MKkZ3qXeIjvZfeC0AJlVKjwQCFjw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
@ -242,6 +249,8 @@
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
"meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

View file

@ -1,4 +1,85 @@
# 1.11.9
# 1.11.17
- Добавлена стратегия загрузки `WEB_SABR` для YouTube (#1873), которая позволяет загружать видео без авторизации на YouTube
- Добавлено уведомление, если для получения аудио требуется авторизация в YouTube (#1873)
- Исправлена работа Adaptive volume в Safari (#1873)
- Добавлена поддержка Dropout.tv в (#1872)
- Исправлен неправильный селектор контейнера для JOIDatabase (#1851)
# 1.11.16
- Исправлена ошибка из-за которой повторное нажатие на кнопку перевода не сбрасывало состояние кнопки перевода, если в ней отображалась ошибка
# 1.11.15
- [эксп.] При загрузке через Web ABR сначала пытаемся загрузить аудио с использованием анонимного доступа, а при необходимости с авторизованной сессией (от @Acobat12)
# 1.11.14
- Улучшена работа стратегии `WEB_ABR` (#1846):
- Ускорена загрузка аудио с YouTube
- Снижен расход трафика за счёт выбора аудиофайла меньшего размера
- Улучшен выбор языка аудиодорожки для видео с несколькими аудиодорожками
- Добавлен прогрессивный таймаут для отправки аудио при медленном интернете
- Добавлен повтор перевода нажатием на кнопку «Повторить перевод» (#1846)
- Добавлена поддержка сайта JOI Database (`https://www.the-joi-database.com/`) (спасибо @DeMoN288 в [vot.js#132](https://github.com/FOSWLY/vot.js/issues/132))
- Из списка выбора языков удалены "Литовский" и "Латышский" языки (больше не поддерживаются API перевода)
# 1.11.13
> [!CAUTION]
> Если вы используете кастомный адрес прокси-сервера (он же [vot-worker](https://github.com/FOSWLY/vot-worker)), пожалуйста, убедитесь, что он обновлен до новой версии - 1.1.0 ([подробнее](https://github.com/FOSWLY/vot-worker/blob/main/changelog.md#110)). Из-за изменения логики запросов, расширение не будет работать со старыми версиями [vot-worker](https://github.com/FOSWLY/vot-worker)!
- [!] Изменён внутренний провайдер взаимодействия с [vot-worker](https://github.com/FOSWLY/vot-worker).
Теперь, все запросы к прокси-серверу происходят без дополнительной сериализации в JSON, что позволило уменьшить потребление трафика до 4х раз! Также, благодаря этому, удалось уменьшить количество ошибок вида "Audio downloader failed while uploading chunk" происходящих из-за слишком большого тела запроса
- Улучшена работа стратегии `WEB_ABR`
- для мультиязычных видео выбирается аудиодорожка на языке оригинала, либо если ее не удается найти, то выбирается дорожка "по умолчанию"
- предотвращена одновременная загрузка одного и того же видео в некоторых случаях
- исправлена отправка пустого финального аудиочанка, из-за которой перевод мог не переходить к следующему этапу (спасибо @Acobat12)
- улучшен выбор аудиоформата и обновление истёкших URL
- Исправлена работа Netacad (#1837)
# 1.11.12
- Реализована `WEB_ABR` - новая гибридная стратегия загрузки аудиодорожки с ютуба.
- Она работает в разы быстрее чем старая `WEB_MSE_PROXY` (старая стратегия теперь используется в качестве fallback)
- Работает в загрузчиках юзерскриптов в отличие от `WEB_MSE_PROXY`
- При наличии авторизации на YouTube, поддерживает загрузку видео, которые имеют "Ограничение по возрасту" (age-restricted) и были недоступны со старой стратегией
<details>
<summary>Подробнее о реализации</summary>
По своей реализации это гибрид стратегий `WEB_ABR` и `WEB_SABR` используемых Яндексом с актуальными методами из yt-dlp.
Плюсы:
- вручную собирает сессию Innertube и минтит poToken, а не надеется получить все готовые данные из плеера
- использует несколько различных вариантов клиента (`web_embedded` -> `tv_downgraded` -> `web` -> `web_creator` (требует вход в YouTube аккаунт, но позволяет переводить age-restricted видео)) (порт из yt-dlp)
- почанковая загрузка
- приоритеты itag из оригинального `WEB_ABR`
Минусы:
- повышает размер бандла на ~25%
</details>
- Переработана внутренняя архитектура загрузки аудио
- Улучшена обработка ошибок и отмены загрузки аудио
# 1.11.11
- Расширена поддержка ссылок Coursera. Добавлена поддержка Labs и некоторых других типов ссылок (#1812, [vot.js#130](https://github.com/FOSWLY/vot.js/pull/130))
- Исправлена работа стратегии mseProxy с длинными видео. Теперь, загрузка аудио не прерывается по жестко заданному короткому таймауту
- Увеличено максимальное время ожидания загрузки плеера до 30 секунд
- Актуализированы имитируемые данные браузера в запросах к апи перевода видео
- Добавлено больше дебаг логов для mseProxy
# 1.11.10
- Исправлен баг из-за которого происходил двойной запрос загрузки аудио дорожки видеоролика на YouTube (#1822)
# 1.11.9
- Исправлена загрузка аудио дорожки видеоролика на YouTube, что ранее приводило к ошибкам вида "Возникла ошибка при переводе, попробуйте позже" (#1820)

View file

@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?>
<gupdate xmlns='http://www.google.com/update2/response' protocol='2.0'>
<app appid='dnioaagdjgpcokckfpokpndoblenmfcg'>
<updatecheck codebase='https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-chrome.zip' version='1.11.9' />
<updatecheck codebase='https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-chrome.zip' version='1.11.17' />
</app>
</gupdate>

Binary file not shown.

View file

@ -3,7 +3,7 @@
"vot-ext@firefox": {
"updates": [
{
"version": "1.11.9",
"version": "1.11.17",
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox.xpi"
}
]

Binary file not shown.

238
dist/vot-min.user.js vendored

File diff suppressed because one or more lines are too long

18154
dist/vot.user.js vendored

File diff suppressed because it is too large Load diff

View file

@ -49,11 +49,14 @@
},
"dependencies": {
"@toil/gm-types": "^1.0.5",
"@vot.js/ext": "^3.0.2",
"@vot.js/core": "^3.0.2",
"@vot.js/shared": "^3.0.2",
"@vot.js/ext": "^3.1.5",
"@vot.js/core": "^3.1.5",
"@vot.js/shared": "^3.1.5",
"astring": "1.9.0",
"bowser": "^2.14.1",
"chaimu": "^1.1.0",
"lit-html": "^3.3.3"
"googlevideo": "^4.1.1",
"lit-html": "^3.3.3",
"meriyah": "6.1.4"
}
}

View file

@ -310,6 +310,16 @@ Available paths:
- /video/VIDEO_ID
- /videos/VIDEO_ID
## Joidatabase
Status: [✅] Working
Available (sub)domains:
- `s1.the-joi-database.com`
- `the-joi-database.com`
- `www.the-joi-database.com`
## Picarto
Status: [✅] Working
@ -903,23 +913,6 @@ Limitations:
- To ensure that the script works, you need to [enable the "Bypass Media CSP" setting](https://github.com/ilyhalight/voice-over-translation/wiki/%5BEN%5D-FAQ) in the extension or delete the CSP in another way
## Incestflix
Status: [✅] Working
Available (sub)domains:
- `incestflix.net`
- `incestflix.to`
- `incestflix.com`
- `www.incestflix.net`
- `www.incestflix.to`
- `www.incestflix.com`
Available paths:
- /watch/VIDEO_ID
## Dzen
Status: [✅] Working
@ -1171,6 +1164,26 @@ Available (sub)domains:
- `skilljar.com`
## Dropout
Status: [✅] Working
Available (sub)domains:
- `watch.dropout.tv`
Limitations:
- To ensure that the script works, you need to [enable the "Bypass Media CSP" setting](https://github.com/ilyhalight/voice-over-translation/wiki/%5BEN%5D-FAQ) in the extension or delete the CSP in another way
## Wistia
Status: [✅] Working
Available (sub)domains:
- `fast.wistia.net`
## Direct link to MP4/WEBM
Status: [✅] Working

View file

@ -310,6 +310,16 @@
- /video/VIDEO_ID
- /videos/VIDEO_ID
## Joidatabase
Статус: [✅] Работает
Доступные (под)домены:
- `s1.the-joi-database.com`
- `the-joi-database.com`
- `www.the-joi-database.com`
## Picarto
Статус: [✅] Работает
@ -903,23 +913,6 @@
- Для гарантированной работы скрипта необходимо [включить настройку "Обход Media CSP"](https://github.com/ilyhalight/voice-over-translation/wiki/%5BRU%5D-FAQ) в расширение или удалить CSP другим способом
## Incestflix
Статус: [✅] Работает
Доступные (под)домены:
- `incestflix.net`
- `incestflix.to`
- `incestflix.com`
- `www.incestflix.net`
- `www.incestflix.to`
- `www.incestflix.com`
Доступные пути:
- /watch/VIDEO_ID
## Dzen
Статус: [✅] Работает
@ -1171,6 +1164,26 @@
- `skilljar.com`
## Dropout
Статус: [✅] Работает
Доступные (под)домены:
- `watch.dropout.tv`
Ограничения:
- Для гарантированной работы скрипта необходимо [включить настройку "Обход Media CSP"](https://github.com/ilyhalight/voice-over-translation/wiki/%5BRU%5D-FAQ) в расширение или удалить CSP другим способом
## Wistia
Статус: [✅] Работает
Доступные (под)домены:
- `fast.wistia.net`
## Direct link to MP4/WEBM
Статус: [✅] Работает

View file

@ -286,6 +286,9 @@ const siteData = {
niconico: {
paths: ["/watch/VIDEO_ID"],
},
dropout: {
domains: ["watch.dropout.tv"],
},
directlink: {
paths: ["/*.mp4", "/*.webm"],
limits: [locales.noLocalLinks],

View file

@ -1,4 +1,4 @@
import { VOTWorkerProvider } from "@vot.js/core/providers/votworker";
import { VOTNextWorkerProvider } from "@vot.js/core/providers/votworker";
import { YandexProvider } from "@vot.js/core/providers/yandex";
import type { VOTOpts } from "@vot.js/core/types/client";
import VOTClient from "@vot.js/ext/client";
@ -139,6 +139,7 @@ export class VideoHandler {
site!: ServiceConf;
translateFromLang: RequestLang = "auto";
autoSourceLanguageOverrideVideoId?: string;
translateToLang: ResponseLang = calculatedResLang;
data?: Partial<StorageData>;
@ -734,7 +735,7 @@ export class VideoHandler {
? this.data?.account?.token
: undefined,
host: transportHost,
provider: proxyClientEnabled ? VOTWorkerProvider : YandexProvider,
provider: proxyClientEnabled ? VOTNextWorkerProvider : YandexProvider,
};
this.votClient = new VOTClient(this.votOpts);
this.votClient.provider.sessions = await this.votSessionStorage.restore(

View file

@ -2,4 +2,5 @@ Contains the implementation for extracting the audio track from a YouTube video
Implemented:
- `web_abr`
- `web_mse_proxy`

View file

@ -1,50 +1,55 @@
import { EventImpl } from "../core/eventImpl";
import type {
AudioDownloadRequestOptions,
DownloadedAudioData,
DownloadedPartialAudioData,
} from "../types/audioDownloader";
import { throwIfAborted } from "../utils/abort";
import debug from "../utils/debug";
import { isAbortError, makeAbortError } from "../utils/errors";
import { EventImpl } from "../utils/eventImpl";
import {
type AvailableAudioDownloadType,
SABR_STRATEGY,
strategies,
WEB_ABR_STRATEGY,
WEB_MSE_PROXY_STRATEGY,
} from "./strategies";
function assertHasAudioChunk(chunk: Uint8Array | undefined): Uint8Array {
if (!chunk || chunk.byteLength === 0) {
throw new Error("Audio downloader. Empty audio");
}
return chunk;
}
import type { AudioChunk } from "./strategies/audioChunks";
async function handleCommonAudioDownloadRequest({
audioDownloader,
attemptedStrategy,
translationId,
videoId,
signal,
}: AudioDownloadRequestOptions) {
const audioData = await strategies[audioDownloader.strategy]({
sourceLanguage,
}: AudioDownloadRequestOptions & {
attemptedStrategy: AvailableAudioDownloadType;
}) {
const audioData = await strategies[attemptedStrategy]({
videoId,
signal,
sourceLanguage,
});
if (!audioData) {
throw new Error("Audio downloader. Can not get audio data");
}
debug.log("Audio downloader. Url found", {
audioDownloadType: audioDownloader.strategy,
audioDownloadType: attemptedStrategy,
});
const { getMediaBuffers, fileId } = audioData;
throwIfAborted(signal);
// One-item lookahead: hold each real chunk until the next item (or clean
// EOF) confirms it, so a terminal zero-byte marker is absorbed and the
// last real chunk is sent as terminal with amount = total. Nothing held is
// dispatched if iteration throws before clean EOF.
let index = 0;
let receivedLastChunk = false;
for await (const { buffer, isLastChunk } of getMediaBuffers()) {
const chunk =
isLastChunk && index > 0 ? buffer : assertHasAudioChunk(buffer);
const amount = isLastChunk ? index + 1 : 0;
let pending: AudioChunk | undefined;
let sawTerminal = false;
const dispatchChunk = async (chunk: Uint8Array, isLastChunk: boolean) => {
await audioDownloader.onDownloadedPartialAudio.dispatchAsync(
translationId,
{
@ -53,56 +58,284 @@ async function handleCommonAudioDownloadRequest({
audioData: chunk,
version: 1,
index,
amount,
amount: isLastChunk ? index + 1 : 0,
},
);
receivedLastChunk ||= isLastChunk;
// Upload handlers may abort the run.
throwIfAborted(signal);
index++;
};
for await (const raw of getMediaBuffers()) {
throwIfAborted(signal);
if (sawTerminal) {
// A real last chunk is final; only trailing empty markers are allowed.
if (raw.buffer.byteLength === 0 && raw.isLastChunk) continue;
throw new Error(
"Audio downloader. Malformed audio stream after last chunk",
);
}
if (raw.isLastChunk) {
if (raw.buffer.byteLength === 0) {
// Terminal marker: promote the held chunk to terminal.
if (!pending) throw new Error("Audio downloader. Empty audio");
await dispatchChunk(pending.buffer, true);
pending = undefined;
} else {
if (pending) await dispatchChunk(pending.buffer, false);
pending = raw;
}
sawTerminal = true;
continue;
}
if (raw.buffer.byteLength === 0) {
throw new Error("Audio downloader. Empty audio");
}
if (pending) await dispatchChunk(pending.buffer, false);
pending = raw;
}
if (!receivedLastChunk) {
throw new Error("Audio downloader. MSE stream ended without a last chunk");
if (pending) {
if (!pending.isLastChunk) {
throw new Error("Audio downloader. Stream ended without a last chunk");
}
await dispatchChunk(pending.buffer, true);
} else if (!sawTerminal) {
throw new Error("Audio downloader. Stream ended without a last chunk");
}
}
// Per-video tails shared by every AudioDownloader instance in this realm.
// The WEB_ABR queue used to live inside the service iframe realm, where each
// download creates a fresh iframe (fresh module state), so same-video runs
// never shared a Map. Queuing here, before any iframe exists, serializes the
// whole run (WEB_ABR plus fallback) FIFO per videoId.
const audioDownloadTails = new Map<string, Promise<void>>();
function waitForPreviousDownload(
previous: Promise<void>,
signal: AbortSignal,
): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(makeAbortError());
return;
}
const onAbort = () => reject(makeAbortError());
signal.addEventListener("abort", onAbort, { once: true });
const settled = () => {
signal.removeEventListener("abort", onAbort);
resolve();
};
// A failed predecessor must not fail the waiter; it only gates ordering.
previous.then(settled, settled);
});
}
async function acquireAudioDownloadSlot(
videoId: string,
signal: AbortSignal,
): Promise<() => void> {
throwIfAborted(signal);
const previous = audioDownloadTails.get(videoId) ?? Promise.resolve();
let resolveOwn!: () => void;
const ownDone = new Promise<void>((resolve) => {
resolveOwn = resolve;
});
const tail = previous.then(
() => ownDone,
() => ownDone,
);
audioDownloadTails.set(videoId, tail);
const cleanup = () => {
if (audioDownloadTails.get(videoId) === tail) {
audioDownloadTails.delete(videoId);
}
};
tail.then(cleanup, cleanup);
try {
await waitForPreviousDownload(previous, signal);
throwIfAborted(signal);
} catch (error) {
// Settle our link so later callers still wait for the real predecessor.
resolveOwn();
throw error;
}
return resolveOwn;
}
export class AudioDownloader {
// Only the most recent completed download is kept so a failed upload can
// resume the same video without re-downloading. Completing a different
// video replaces it; this is a single-entry cache, not an LRU/TTL.
private completedAudioCache: {
videoId: string;
fileId: string;
chunks: Uint8Array[];
version: 1;
} | null = null;
private readonly collectingChunks = new Map<string, Uint8Array[]>();
onDownloadedAudio = new EventImpl<[string, DownloadedAudioData]>();
onDownloadedPartialAudio = new EventImpl<
[string, DownloadedPartialAudioData]
>();
onDownloadAudioError = new EventImpl<[string]>();
onDownloadAudioError = new EventImpl<[string, string, boolean]>();
strategy: AvailableAudioDownloadType;
constructor(strategy: AvailableAudioDownloadType = WEB_MSE_PROXY_STRATEGY) {
constructor(strategy: AvailableAudioDownloadType = "auto") {
this.strategy = strategy;
this.onDownloadedPartialAudio.addListener((_translationId, data) => {
const chunks = this.collectingChunks.get(data.videoId);
if (!chunks) return;
chunks[data.index] = data.audioData.slice();
if (
data.amount !== undefined &&
data.amount > 0 &&
data.index === data.amount - 1
) {
this.completedAudioCache = {
videoId: data.videoId,
fileId: data.fileId,
chunks: chunks.slice(0, data.amount),
version: data.version,
};
this.collectingChunks.delete(data.videoId);
debug.log("[VOT][AudioDownload] prepared audio cached for retry", {
videoId: data.videoId,
chunks: data.amount,
});
}
});
debug.log("Audio downloader created", {
strategy,
});
}
clearCachedAudio(videoId: string) {
if (this.completedAudioCache?.videoId === videoId) {
this.completedAudioCache = null;
}
this.collectingChunks.delete(videoId);
}
private async replayCachedAudio(
videoId: string,
translationId: string,
signal: AbortSignal,
): Promise<boolean> {
const cached = this.completedAudioCache;
if (cached?.videoId !== videoId) return false;
debug.log("[VOT][AudioDownload] replaying cached prepared audio", {
videoId,
chunks: cached.chunks.length,
});
for (let index = 0; index < cached.chunks.length; index++) {
throwIfAborted(signal);
await this.onDownloadedPartialAudio.dispatchAsync(translationId, {
videoId,
fileId: cached.fileId,
audioData: cached.chunks[index] ?? new Uint8Array(),
version: cached.version,
index,
amount: index === cached.chunks.length - 1 ? cached.chunks.length : 0,
});
}
return true;
}
async runAudioDownload(
videoId: string,
translationId: string,
signal: AbortSignal,
sourceLanguage?: string,
) {
if (await this.replayCachedAudio(videoId, translationId, signal)) {
return;
}
let release: (() => void) | undefined;
try {
await handleCommonAudioDownloadRequest({
audioDownloader: this,
translationId,
videoId,
signal,
});
debug.log("Audio downloader. Audio download finished", {
release = await acquireAudioDownloadSlot(videoId, signal);
} catch (error) {
if (signal.aborted || isAbortError(error)) {
debug.log("Audio downloader. Audio download aborted", {
videoId,
});
return;
}
debug.error("Audio downloader. All audio download strategies failed", {
videoId,
});
} catch (err) {
debug.error("Audio downloader. Failed to download audio", {
this.onDownloadAudioError.dispatch(translationId, videoId, false);
return;
}
let collecting: Uint8Array[] | undefined;
try {
// A predecessor may have finished the same video while this run waited.
if (await this.replayCachedAudio(videoId, translationId, signal)) {
return;
}
// Buffer chunks in a run-local array so an abort/failure can drop them.
// The identity guard keeps a queued same-video run from losing its own
// collection when an overlapping predecessor cleans up.
collecting = [];
this.collectingChunks.set(videoId, collecting);
const attempts: AvailableAudioDownloadType[] =
this.strategy === "auto"
? [SABR_STRATEGY, WEB_ABR_STRATEGY, WEB_MSE_PROXY_STRATEGY]
: [this.strategy];
for (const attemptedStrategy of attempts) {
try {
await handleCommonAudioDownloadRequest({
audioDownloader: this,
attemptedStrategy,
translationId,
videoId,
signal,
sourceLanguage,
});
debug.log("Audio downloader. Audio download finished", {
videoId,
sourceLanguage,
audioDownloadType: attemptedStrategy,
});
return;
} catch (error) {
if (signal.aborted || isAbortError(error)) {
debug.log("Audio downloader. Audio download aborted", {
videoId,
audioDownloadType: attemptedStrategy,
});
return;
}
if (
attemptedStrategy === WEB_ABR_STRATEGY &&
error instanceof Error &&
error.message.includes("YOUTUBE_SIGN_IN_SUGGESTED")
) {
this.onDownloadAudioError.dispatch(translationId, videoId, true);
return;
}
debug.error("Audio downloader. Strategy failed", {
videoId,
audioDownloadType: attemptedStrategy,
error: error instanceof Error ? error.message : String(error),
});
}
}
debug.error("Audio downloader. All audio download strategies failed", {
videoId,
error: err instanceof Error ? err.message : String(err),
});
this.onDownloadAudioError.dispatch(videoId);
this.onDownloadAudioError.dispatch(translationId, videoId, false);
} finally {
if (collecting && this.collectingChunks.get(videoId) === collecting) {
this.collectingChunks.delete(videoId);
}
release();
}
}
@ -116,7 +349,11 @@ export class AudioDownloader {
): this;
addEventListener(
type: "downloadAudioError",
listener: (videoId: string) => void,
listener: (
translationId: string,
videoId: string,
signInSuggested: boolean,
) => void,
): this;
addEventListener(
type: "downloadedAudio" | "downloadedPartialAudio" | "downloadAudioError",
@ -147,7 +384,11 @@ export class AudioDownloader {
): this;
removeEventListener(
type: "downloadAudioError",
listener: (videoId: string) => void,
listener: (
translationId: string,
videoId: string,
signInSuggested: boolean,
) => void,
): this;
removeEventListener(
type: "downloadedAudio" | "downloadedPartialAudio" | "downloadAudioError",

View file

@ -0,0 +1,16 @@
export type AudioChunk = {
buffer: Uint8Array;
isLastChunk: boolean;
};
export function concatBuffers(buffers: Uint8Array[]): Uint8Array {
const result = new Uint8Array(
buffers.reduce((length, buffer) => length + buffer.byteLength, 0),
);
let offset = 0;
for (const buffer of buffers) {
result.set(buffer, offset);
offset += buffer.byteLength;
}
return result;
}

View file

@ -0,0 +1,25 @@
import { AudioDownloadType } from "@vot.js/core/types/providers/yandex";
/**
* SABR is intentionally a distinct internal strategy. It is not folded into
* WEB_ABR: this keeps selection and diagnostics honest while avoiding a hard
* dependency on a core enum value that may not exist yet.
*/
export const SABR_STRATEGY = AudioDownloadType.WEB_SABR;
export const WEB_ABR_STRATEGY = AudioDownloadType.WEB_ABR;
export const WEB_MSE_PROXY_STRATEGY = AudioDownloadType.WEB_MSE_PROXY;
export type AudioBridgeStrategy =
| typeof SABR_STRATEGY
| typeof WEB_ABR_STRATEGY
| typeof WEB_MSE_PROXY_STRATEGY;
export function isAudioBridgeStrategy(
value: unknown,
): value is AudioBridgeStrategy {
return (
value === SABR_STRATEGY ||
value === WEB_ABR_STRATEGY ||
value === WEB_MSE_PROXY_STRATEGY
);
}

View file

@ -1,11 +1,22 @@
import { AudioDownloadType } from "@vot.js/core/types/yandex";
import type { GetAudioFromAPIOptions } from "../../types/audioDownloader";
import {
SABR_STRATEGY,
WEB_ABR_STRATEGY,
WEB_MSE_PROXY_STRATEGY,
} from "./audioStrategy";
import { getAudioFromBridge } from "./webAudioBridge";
import { getAudioFromWebMseProxy } from "./webMseProxy";
import "./mseProxyHandler";
export const WEB_MSE_PROXY_STRATEGY = AudioDownloadType.WEB_MSE_PROXY;
export { SABR_STRATEGY, WEB_ABR_STRATEGY, WEB_MSE_PROXY_STRATEGY };
export const strategies = {
[WEB_MSE_PROXY_STRATEGY]: getAudioFromWebMseProxy,
[SABR_STRATEGY]: (options: GetAudioFromAPIOptions) =>
getAudioFromBridge(options, SABR_STRATEGY),
[WEB_ABR_STRATEGY]: (options: GetAudioFromAPIOptions) =>
getAudioFromBridge(options, WEB_ABR_STRATEGY),
[WEB_MSE_PROXY_STRATEGY]: (options: GetAudioFromAPIOptions) =>
getAudioFromBridge(options, WEB_MSE_PROXY_STRATEGY),
} as const;
export type AvailableAudioDownloadType = keyof typeof strategies;
export type AvailableAudioDownloadType = keyof typeof strategies | "auto";

View file

@ -1,7 +1,19 @@
import { AudioDownloadType } from "@vot.js/core/types/yandex";
import { config } from "@vot.js/shared";
import debug from "../../utils/debug";
import { isAbortError } from "../../utils/errors";
import { type AudioChunk, concatBuffers } from "./audioChunks";
import {
type AudioBridgeStrategy,
isAudioBridgeStrategy,
SABR_STRATEGY,
} from "./audioStrategy";
import { getSabrAudioChunks } from "./youtubeSabrStrategy";
import { getWebAbrAudioChunks } from "./youtubeWebAbr";
const MESSAGE_TYPE = "get-audio-chunks-by-mse-in-main-world";
const READY_MESSAGE_TYPE = "vot-mse-proxy-ready";
const IFRAME_HASH = "ya_iframe";
const MIN_CHUNK_SIZE = 5_295_308;
const BOOT_KEY = "__VOT_MSE_PROXY_HANDLER__";
const STORE_KEY = "__VOT_MSE_CAPTURE_STORE__";
@ -22,16 +34,22 @@ type MseWindow = Window & {
type MseMessage = {
messageId: string;
messageType: string;
messageDirection: "request" | "response";
messageDirection: "request" | "response" | "iframe-response";
payload?: unknown;
error?: string;
isAborted?: boolean;
isStreamFinished?: boolean;
isProgress?: boolean;
};
const topSessions = new Map<
string,
{ iframe: HTMLIFrameElement; cleanup: () => void }
{
iframe: HTMLIFrameElement;
cleanup: () => void;
source: MessageEventSource;
origin: string;
}
>();
function getVideoId(message: MseMessage): string | undefined {
@ -40,6 +58,26 @@ function getVideoId(message: MseMessage): string | undefined {
return typeof videoId === "string" ? videoId : undefined;
}
function getAudioDownloadType(
message: MseMessage,
): AudioBridgeStrategy | undefined {
if (!message.payload || typeof message.payload !== "object") return;
const audioDownloadType = (message.payload as { audioDownloadType?: unknown })
.audioDownloadType;
return isAudioBridgeStrategy(audioDownloadType)
? audioDownloadType
: undefined;
}
function getSourceLanguage(message: MseMessage): string | undefined {
if (!message.payload || typeof message.payload !== "object") return;
const sourceLanguage = (message.payload as { sourceLanguage?: unknown })
.sourceLanguage;
return typeof sourceLanguage === "string" && sourceLanguage
? sourceLanguage
: undefined;
}
async function getEncryptedEmbedConfig(
targetWindow: Window,
videoId: string,
@ -85,35 +123,40 @@ type CapturedEvent =
| { type: "end" }
| { type: "close" };
function concatBuffers(buffers: Uint8Array[]): Uint8Array {
const result = new Uint8Array(
buffers.reduce((length, buffer) => length + buffer.byteLength, 0),
);
let offset = 0;
for (const buffer of buffers) {
result.set(buffer, offset);
offset += buffer.byteLength;
}
return result;
}
function waitFor<T>(
getValue: () => T | null,
timeoutMs: number,
label: string,
signal: AbortSignal,
): Promise<T> {
return new Promise((resolve, reject) => {
const startedAt = performance.now();
const cleanup = () => {
clearInterval(interval);
clearTimeout(timeout);
signal.removeEventListener("abort", onAbort);
};
const onAbort = () => {
cleanup();
reject(signal.reason);
};
const interval = setInterval(() => {
const value = getValue();
if (value) {
clearInterval(interval);
resolve(value);
} else if (performance.now() - startedAt >= timeoutMs) {
clearInterval(interval);
reject(new Error(`Audio downloader. ${label} timed out`));
try {
const value = getValue();
if (value) {
cleanup();
resolve(value);
}
} catch (error) {
cleanup();
reject(error);
}
}, 100);
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Audio downloader. ${label} timed out`));
}, timeoutMs);
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
});
}
@ -153,7 +196,12 @@ class CapturedMediaSource {
listen(listener: (event: CapturedEvent) => void): () => void {
this.listeners.add(listener);
for (const event of this.queuedEvents.splice(0)) listener(event);
try {
for (const event of this.queuedEvents.splice(0)) listener(event);
} catch (error) {
this.listeners.delete(listener);
throw error;
}
return () => this.listeners.delete(listener);
}
@ -191,7 +239,7 @@ class MseCaptureStore {
for (const listener of this.listeners) listener(capture);
}
async pick(): Promise<CapturedMediaSource> {
async pick(signal: AbortSignal): Promise<CapturedMediaSource> {
try {
return await waitFor(
() => {
@ -203,8 +251,11 @@ class MseCaptureStore {
},
10_000,
"MSE capture wait",
signal,
);
} catch (error) {
signal.throwIfAborted();
if (isAbortError(error)) throw error;
const newest = this.captures.at(-1);
throw new Error(
`Audio downloader. MSE capture wait timed out (captures: ${this.captures.length}, ` +
@ -245,7 +296,10 @@ function installMediaSourceProxy(targetWindow: MseWindow): MseCaptureStore {
return store;
}
async function getPlayer(targetWindow: Window): Promise<YouTubePlayer> {
async function getPlayer(
targetWindow: Window,
signal: AbortSignal,
): Promise<YouTubePlayer> {
return await waitFor(
() => {
const player =
@ -257,22 +311,50 @@ async function getPlayer(targetWindow: Window): Promise<YouTubePlayer> {
? player
: null;
},
10_000,
30_000,
"MSE player wait",
signal,
);
}
function createAudioChunkStream(
export function createAudioChunkStream(
targetWindow: MseWindow,
videoId: string,
signal: AbortSignal,
): ReadableStream<{ buffer: Uint8Array; isLastChunk: boolean }> {
onProgress?: () => void,
): ReadableStream<AudioChunk> {
let cleanup = () => {};
let finished = false;
const cancellation = new AbortController();
signal = AbortSignal.any([signal, cancellation.signal]);
return new ReadableStream({
async start(controller) {
let stopMse = () => {};
const fail = (error: unknown) => {
if (finished) return;
finished = true;
cleanup();
controller.error(error);
};
const onAbort = () => fail(signal.reason);
cleanup = () => {
stopMse();
signal.removeEventListener("abort", onAbort);
};
signal.addEventListener("abort", onAbort, { once: true });
const onMseError = (error: unknown) => {
if (finished) return;
stopMse();
fail(signal.aborted ? signal.reason : error);
};
try {
const player = await getPlayer(targetWindow);
signal.throwIfAborted();
debug.log("Audio downloader. MSE iframe stream started", { videoId });
const player = await getPlayer(targetWindow, signal);
signal.throwIfAborted();
debug.log("Audio downloader. MSE player found", { videoId });
try {
player.loadVideoById?.(videoId);
} catch {
@ -340,8 +422,11 @@ function createAudioChunkStream(
},
15_000,
"MSE media wait",
signal,
);
} catch (error) {
signal.throwIfAborted();
if (isAbortError(error)) throw error;
const videos = listVideos();
const video = videos[0];
// The proxy is installed at handler init, so captures collected
@ -361,6 +446,13 @@ function createAudioChunkStream(
{ cause: error },
);
}
signal.throwIfAborted();
debug.log("Audio downloader. MSE media ready", {
videoId,
readyState: readyVideo.readyState,
playerState: getPlayerState(),
playReject,
});
try {
readyVideo.playbackRate = 2;
} catch {
@ -368,92 +460,153 @@ function createAudioChunkStream(
}
const store = installMediaSourceProxy(targetWindow);
let capture = await store.pick();
let capture = await store.pick(signal);
signal.throwIfAborted();
debug.log("Audio downloader. MSE capture picked", {
videoId,
captures: store.captures.length,
readyState: capture.mediaSource.readyState,
});
let removeCaptureListener = () => {};
let pending: Uint8Array[] = [];
let pendingSize = 0;
let heldChunk: Uint8Array | undefined;
let totalSize = 0;
let seekTimeout: ReturnType<typeof setTimeout> | undefined;
let closed = false;
let lastProgressAt = 0;
const promotePendingChunk = () => {
if (heldChunk) {
controller.enqueue({ buffer: heldChunk, isLastChunk: false });
}
heldChunk = concatBuffers(pending);
const enqueuePendingChunk = (isLastChunk: boolean) => {
const size = pendingSize;
controller.enqueue({ buffer: concatBuffers(pending), isLastChunk });
debug.log("Audio downloader. MSE chunk enqueued", {
videoId,
size,
isLastChunk,
totalSize,
});
pending = [];
pendingSize = 0;
};
const close = () => {
if (closed) return;
closed = true;
if (pendingSize > 0) promotePendingChunk();
if (!heldChunk?.byteLength) {
controller.error(new Error("Audio downloader. Empty MSE stream"));
if (finished) return;
if (totalSize === 0) {
debug.error("Audio downloader. MSE empty stream", { videoId });
void onMseError(new Error("Audio downloader. Empty MSE stream"));
} else {
controller.enqueue({ buffer: heldChunk, isLastChunk: true });
debug.log("Audio downloader. MSE stream finished", {
videoId,
totalSize,
});
enqueuePendingChunk(true);
finished = true;
controller.close();
}
cleanup();
};
const onCapturedEvent = (event: CapturedEvent) => {
if (closed) return;
if (event.type === "end") {
close();
return;
}
if (event.type === "close") {
closed = true;
controller.error(new Error("Audio downloader. MSE source closed"));
cleanup();
return;
}
};
let firstAppendLogged = false;
const onCapturedEvent = (event: CapturedEvent) => {
if (finished) return;
try {
if (event.type === "end") {
debug.log("Audio downloader. MSE end of stream", {
videoId,
totalSize,
pendingSize,
});
close();
return;
}
if (event.type === "close") {
debug.error("Audio downloader. MSE source closed", {
videoId,
totalSize,
});
void onMseError(new Error("Audio downloader. MSE source closed"));
return;
}
pending.push(event.buffer);
pendingSize += event.buffer.byteLength;
if (pendingSize >= MIN_CHUNK_SIZE) promotePendingChunk();
if (!firstAppendLogged) {
firstAppendLogged = true;
debug.log("Audio downloader. MSE first audio append", {
videoId,
size: event.buffer.byteLength,
});
}
pending.push(event.buffer);
pendingSize += event.buffer.byteLength;
totalSize += event.buffer.byteLength;
if (pendingSize >= config.minChunkSize) {
enqueuePendingChunk(false);
} else if (
pendingSize >= config.minChunkSize / 2 &&
performance.now() - lastProgressAt >= 30_000
) {
// Half a chunk is buffered but no full chunk yet: ping the
// main world so it extends the message timeout. Chunk sizes
// stay strictly bound to config.minChunkSize.
lastProgressAt = performance.now();
debug.log("Audio downloader. MSE progress ping", {
videoId,
pendingSize,
totalSize,
});
onProgress?.();
}
const { buffered } = event.sourceBuffer;
const bufferedEnd =
buffered.length > 0
? Math.floor(buffered.end(buffered.length - 1))
: 0;
clearTimeout(seekTimeout);
if (bufferedEnd > 0) {
seekTimeout = setTimeout(
() => player.seekTo(bufferedEnd, true),
1000,
);
if (finished) return;
const { buffered } = event.sourceBuffer;
const bufferedEnd =
buffered.length > 0
? Math.floor(buffered.end(buffered.length - 1))
: 0;
clearTimeout(seekTimeout);
if (bufferedEnd > 0) {
seekTimeout = setTimeout(() => {
try {
player.seekTo(bufferedEnd, true);
} catch (error) {
void onMseError(error);
}
}, 1000);
}
} catch (error) {
void onMseError(error);
}
};
let stopCapture = () => {};
const onAbort = () => {
if (closed) return;
closed = true;
controller.error(new Error(String(signal.reason ?? "Aborted")));
cleanup();
};
cleanup = () => {
stopMse = () => {
clearTimeout(seekTimeout);
stopCapture();
removeCaptureListener();
signal.removeEventListener("abort", onAbort);
};
stopCapture = capture.listen(onCapturedEvent);
if (closed) return;
removeCaptureListener = store.onCapture((nextCapture) => {
// listen() replays queued events before returning its unsubscribe.
if (finished) {
stopCapture();
capture = nextCapture;
stopCapture = capture.listen(onCapturedEvent);
return;
}
removeCaptureListener = store.onCapture((nextCapture) => {
if (finished) return;
try {
stopCapture();
capture = nextCapture;
stopCapture = capture.listen(onCapturedEvent);
if (finished) stopCapture();
} catch (error) {
void onMseError(error);
}
});
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
if (finished) removeCaptureListener();
} catch (error) {
controller.error(error);
cleanup();
debug.error("Audio downloader. MSE iframe stream failed", {
videoId,
error: error instanceof Error ? error.message : String(error),
});
onMseError(error);
}
},
cancel() {
cancel(reason) {
finished = true;
cancellation.abort(reason);
cleanup();
},
});
@ -477,47 +630,112 @@ async function handleIframeRequest(
const controller = new AbortController();
const abort = (abortEvent: MessageEvent<MseMessage>) => {
const data = abortEvent.data;
if (data.messageId === message.messageId && data.isAborted) {
if (
abortEvent.source === source &&
abortEvent.origin === event.origin &&
data.messageId === message.messageId &&
data.messageType === MESSAGE_TYPE &&
data.messageDirection === "request" &&
data.isAborted
) {
controller.abort(data.payload);
}
};
targetWindow.addEventListener("message", abort);
let settled = false;
let heartbeat: ReturnType<typeof setInterval> | undefined;
try {
const videoId = getVideoId(message);
if (!videoId) throw new Error("Audio downloader. Missing video id");
const stream = createAudioChunkStream(
targetWindow,
const audioDownloadType = getAudioDownloadType(message);
if (!audioDownloadType) {
throw new Error("Audio downloader. Unsupported audio download type");
}
debug.log("Audio downloader. iframe request started", {
videoId,
controller.signal,
);
await stream.pipeTo(
new WritableStream({
write(chunk) {
postResponse(source, event.origin, {
...message,
messageDirection: "response",
payload: chunk,
});
},
close() {
postResponse(source, event.origin, {
...message,
messageDirection: "response",
payload: undefined,
isStreamFinished: true,
});
},
}),
);
} catch (error) {
messageId: message.messageId,
audioDownloadType,
});
const postProgress = () => {
if (settled) return;
postResponse(source, event.origin, {
...message,
messageDirection: "iframe-response",
payload: undefined,
isProgress: true,
});
};
const chunks: AsyncIterable<AudioChunk> =
audioDownloadType === SABR_STRATEGY
? getSabrAudioChunks(
targetWindow,
videoId,
controller.signal,
getSourceLanguage(message),
)
: audioDownloadType === AudioDownloadType.WEB_ABR
? getWebAbrAudioChunks(
targetWindow,
videoId,
controller.signal,
getSourceLanguage(message),
)
: createAudioChunkStream(
targetWindow,
videoId,
controller.signal,
postProgress,
);
if (
audioDownloadType === SABR_STRATEGY ||
audioDownloadType === AudioDownloadType.WEB_ABR
) {
postProgress();
heartbeat = setInterval(postProgress, 30_000);
}
for await (const chunk of chunks) {
debug.log("Audio downloader. iframe chunk sent", {
videoId,
messageId: message.messageId,
audioDownloadType,
size: chunk.buffer.byteLength,
isLastChunk: chunk.isLastChunk,
});
postResponse(source, event.origin, {
...message,
messageDirection: "iframe-response",
payload: chunk,
});
}
settled = true;
debug.log("Audio downloader. iframe stream closed", {
videoId,
messageId: message.messageId,
audioDownloadType,
});
postResponse(source, event.origin, {
...message,
messageDirection: "response",
messageDirection: "iframe-response",
payload: undefined,
isStreamFinished: true,
});
} catch (error) {
settled = true;
debug.error("Audio downloader. iframe request failed", {
messageId: message.messageId,
error: error instanceof Error ? error.message : String(error),
});
postResponse(source, event.origin, {
...message,
messageDirection: "iframe-response",
payload: undefined,
error: error instanceof Error ? error.message : String(error),
isAborted: controller.signal.aborted || isAbortError(error),
});
} finally {
clearInterval(heartbeat);
targetWindow.removeEventListener("message", abort);
}
}
@ -532,18 +750,173 @@ async function handleTopRequest(
if (message.isAborted) {
const session = topSessions.get(message.messageId);
session?.iframe.contentWindow?.postMessage(message, "*");
session?.cleanup();
if (session?.source === source && session.origin === event.origin) {
session.iframe.contentWindow?.postMessage(message, "*");
session.cleanup();
}
return;
}
const videoId = getVideoId(message);
if (!videoId) return;
const audioDownloadType = getAudioDownloadType(message);
if (!videoId || !audioDownloadType) {
postResponse(source, event.origin, {
...message,
messageDirection: "response",
error: videoId
? "Audio downloader. Unsupported audio download type"
: "Audio downloader. Missing video id",
});
return;
}
debug.log("Audio downloader. top request started", {
videoId,
messageId: message.messageId,
audioDownloadType,
host: targetWindow.location.hostname,
});
// Run WebABR/SABR directly in the real current YouTube page context.
// The hidden /embed player is retained only for the legacy MSE fallback.
if (
audioDownloadType === SABR_STRATEGY ||
audioDownloadType === AudioDownloadType.WEB_ABR
) {
const controller = new AbortController();
let settled = false;
const abort = (abortEvent: MessageEvent<MseMessage>) => {
const data = abortEvent.data;
if (
abortEvent.source === source &&
abortEvent.origin === event.origin &&
data.messageId === message.messageId &&
data.messageType === MESSAGE_TYPE &&
data.messageDirection === "request" &&
data.isAborted
) {
controller.abort(data.payload);
}
};
targetWindow.addEventListener("message", abort);
const postProgress = () => {
if (settled) return;
postResponse(source, event.origin, {
...message,
messageDirection: "response",
payload: undefined,
isProgress: true,
});
};
const heartbeat = setInterval(postProgress, 30_000);
postProgress();
debug.log("Audio downloader. direct strategy request started", {
strategy: audioDownloadType,
videoId,
messageId: message.messageId,
host: targetWindow.location.hostname,
});
try {
const chunks =
audioDownloadType === SABR_STRATEGY
? getSabrAudioChunks(
targetWindow,
videoId,
controller.signal,
getSourceLanguage(message),
)
: getWebAbrAudioChunks(
targetWindow,
videoId,
controller.signal,
getSourceLanguage(message),
);
for await (const chunk of chunks) {
// Normalize the chunk into this realm before crossing the message
// boundary. Firefox is stricter about structured-cloning objects and
// typed arrays originating in another userscript/page compartment.
// Always copying also makes Safari follow the same deterministic path
// instead of relying on browser-specific cross-realm behaviour.
const buffer = new Uint8Array(
chunk.buffer.buffer,
chunk.buffer.byteOffset,
chunk.buffer.byteLength,
).slice();
const payload: AudioChunk = {
...chunk,
buffer,
};
debug.log("Audio downloader. direct chunk received", {
videoId,
messageId: message.messageId,
size: buffer.byteLength,
isLastChunk: chunk.isLastChunk,
});
try {
postResponse(source, event.origin, {
...message,
messageDirection: "response",
payload,
});
debug.log("Audio downloader. direct chunk posted", {
videoId,
messageId: message.messageId,
size: buffer.byteLength,
isLastChunk: chunk.isLastChunk,
});
} catch (error) {
debug.error("Audio downloader. direct chunk post failed", {
videoId,
messageId: message.messageId,
size: buffer.byteLength,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
settled = true;
debug.log("Audio downloader. direct strategy stream finished", {
strategy: audioDownloadType,
videoId,
messageId: message.messageId,
});
postResponse(source, event.origin, {
...message,
messageDirection: "response",
payload: undefined,
isStreamFinished: true,
});
} catch (error) {
settled = true;
debug.error("Audio downloader. direct WebABR/SABR request failed", {
videoId,
messageId: message.messageId,
error: error instanceof Error ? error.message : String(error),
});
postResponse(source, event.origin, {
...message,
messageDirection: "response",
payload: undefined,
error: error instanceof Error ? error.message : String(error),
isAborted: controller.signal.aborted || isAbortError(error),
});
} finally {
clearInterval(heartbeat);
targetWindow.removeEventListener("message", abort);
}
return;
}
// MSE fallback keeps the original auxiliary /embed player path.
const iframe = targetWindow.document.createElement("iframe");
// display:none iframes have no layout box, and YouTube defers media
// loading for non-rendered players (no src is ever assigned). Keep the
// frame rendered but invisible: 2x2px, off the visual path.
iframe.style.cssText =
"position:fixed;right:0;bottom:0;width:2px;height:2px;border:0;" +
"padding:0;margin:0;opacity:0;visibility:hidden;pointer-events:none;";
@ -551,44 +924,77 @@ async function handleTopRequest(
iframe.setAttribute("aria-hidden", "true");
iframe.id = `vot-mse-proxy-${message.messageId}`;
const url = new URL(`/embed/${videoId}`, "https://www.youtube.com");
url.searchParams.set("html5", "1");
url.searchParams.set("autoplay", "0");
url.searchParams.set("mute", "1");
const embedConfig = await getEncryptedEmbedConfig(targetWindow, videoId);
if (embedConfig) url.searchParams.set("embed_config", embedConfig);
url.hash = IFRAME_HASH;
let active = true;
let timeout: ReturnType<typeof setTimeout> | undefined;
let onMessage = (_event: MessageEvent<MseMessage>) => {};
const cleanup = () => {
active = false;
clearTimeout(timeout);
targetWindow.removeEventListener("message", onMessage);
topSessions.delete(message.messageId);
if (topSessions.get(message.messageId)?.iframe === iframe) {
topSessions.delete(message.messageId);
}
iframe.remove();
};
topSessions.set(message.messageId, {
iframe,
cleanup,
source,
origin: event.origin,
});
const embedConfig = await getEncryptedEmbedConfig(targetWindow, videoId);
if (!active) return;
if (embedConfig) url.searchParams.set("embed_config", embedConfig);
let ready = false;
const onMessage = (responseEvent: MessageEvent<MseMessage>) => {
onMessage = (responseEvent: MessageEvent<MseMessage>) => {
const response = responseEvent.data;
if (responseEvent.source !== iframe.contentWindow) return;
if (response.messageType === READY_MESSAGE_TYPE) {
if (ready) return;
ready = true;
clearTimeout(timeout);
debug.log("Audio downloader. iframe ready", {
videoId,
messageId: message.messageId,
});
iframe.contentWindow?.postMessage(message, "*");
} else if (
response.messageId === message.messageId &&
(response.error || response.isStreamFinished)
response.messageType === MESSAGE_TYPE &&
response.messageDirection === "iframe-response"
) {
queueMicrotask(cleanup);
// The service iframe is cross-origin from non-www YouTube hosts, so the
// bridge would reject its responses. Relay them with the top page origin.
postResponse(source, event.origin, {
...response,
messageDirection: "response",
});
if (response.error || response.isAborted || response.isStreamFinished) {
queueMicrotask(cleanup);
}
}
};
const timeout = setTimeout(() => {
timeout = setTimeout(() => {
debug.error("Audio downloader. iframe loading timed out", {
videoId,
messageId: message.messageId,
ready,
});
postResponse(source, event.origin, {
...message,
messageDirection: "response",
error: "Audio downloader. MSE iframe loading timed out",
error: "Audio downloader. iframe loading timed out",
});
cleanup();
}, 15_000);
topSessions.set(message.messageId, { iframe, cleanup });
targetWindow.addEventListener("message", onMessage);
iframe.src = url.toString();
(

View file

@ -0,0 +1,290 @@
import { browserInfo } from "../../utils/browserInfo";
import debug from "../../utils/debug";
const REQUEST_EVENT = "vot:safari-page-po-token:request";
const RESPONSE_EVENT = "vot:safari-page-po-token:response";
const BRIDGE_MARKER = "data-vot-safari-po-token-bridge";
const TOP_REQUEST_MESSAGE = "vot:safari-page-po-token:top-request";
const TOP_RESPONSE_MESSAGE = "vot:safari-page-po-token:top-response";
let topBrokerInstalled = false;
type TopPoTokenMessage = {
type?: string;
requestId?: string;
binding?: string;
token?: string;
error?: string;
};
type PoTokenResponse = {
requestId?: string;
token?: string;
error?: string;
};
function isSafariBrowser(): boolean {
return browserInfo.browser?.name === "Safari";
}
function installSafariPageBridge(): boolean {
if (!isSafariBrowser()) return false;
if (document.documentElement.hasAttribute(BRIDGE_MARKER)) return true;
const script = document.createElement("script");
const nonceSource =
document.querySelector<HTMLScriptElement>("script[nonce]");
if (nonceSource?.nonce) script.nonce = nonceSource.nonce;
// This source is intentionally self-contained. The injected script executes
// in YouTube's real page realm, where bevasrsg/havuokmhhs-* is available.
script.textContent = `(() => {
const REQUEST_EVENT = ${JSON.stringify(REQUEST_EVENT)};
const RESPONSE_EVENT = ${JSON.stringify(RESPONSE_EVENT)};
const MARKER = ${JSON.stringify(BRIDGE_MARKER)};
if (document.documentElement.hasAttribute(MARKER)) return;
document.documentElement.setAttribute(MARKER, "1");
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function mint(binding) {
const keys = Object.getOwnPropertyNames(globalThis).filter(
(key) => key === "bevasrsg" || key.startsWith("havuokmhhs-"),
);
if (!keys.length) {
throw new Error("YouTube PO token provider is unavailable in page realm");
}
for (const key of keys) {
let bevasrs;
try {
bevasrs = globalThis[key]?.bevasrs;
} catch {
continue;
}
const wpc = bevasrs?.wpc;
if (typeof wpc !== "function") continue;
for (let attempt = 0; attempt < 10; attempt++) {
try {
const minter = await wpc.call(bevasrs);
const token = await minter?.mws?.({
c: binding,
mc: false,
me: false,
});
if (typeof token === "string" && token) return token;
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
if (!message.includes("SDF:notready")) break;
}
await delay(500);
}
}
throw new Error("YouTube PO token mint failed in page realm");
}
addEventListener(REQUEST_EVENT, async (event) => {
const detail = event?.detail;
const requestId = detail?.requestId;
const binding = detail?.binding;
if (typeof requestId !== "string" || typeof binding !== "string" || !binding) {
return;
}
try {
const token = await mint(binding);
dispatchEvent(new CustomEvent(RESPONSE_EVENT, {
detail: { requestId, token },
}));
} catch (error) {
dispatchEvent(new CustomEvent(RESPONSE_EVENT, {
detail: {
requestId,
error: error instanceof Error ? error.message : String(error),
},
}));
}
});
})();`;
(document.documentElement || document.head).append(script);
script.remove();
return document.documentElement.hasAttribute(BRIDGE_MARKER);
}
async function requestSafariPagePoTokenLocal(
binding: string,
signal: AbortSignal,
): Promise<string | undefined> {
if (!isSafariBrowser()) return undefined;
signal.throwIfAborted();
if (!installSafariPageBridge()) {
debug.error("[VOT][PO_TOKEN] Safari page bridge injection failed");
return undefined;
}
const requestId = crypto.randomUUID();
debug.log("[VOT][PO_TOKEN] trying Safari page-realm bridge", {
bindingLength: binding.length,
});
return await new Promise<string | undefined>((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => finish(undefined), 8000);
const cleanup = () => {
clearTimeout(timeout);
removeEventListener(RESPONSE_EVENT, onResponse as EventListener);
signal.removeEventListener("abort", onAbort);
};
const finish = (value: string | undefined) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(signal.reason);
};
const onResponse = (event: Event) => {
const detail = (event as CustomEvent<PoTokenResponse>).detail;
if (detail?.requestId !== requestId) return;
if (typeof detail.token === "string" && detail.token) {
finish(detail.token);
return;
}
if (detail.error) {
debug.error("[VOT][PO_TOKEN] Safari page-realm mint failed", {
message: detail.error,
});
}
finish(undefined);
};
addEventListener(RESPONSE_EVENT, onResponse as EventListener);
signal.addEventListener("abort", onAbort, { once: true });
dispatchEvent(
new CustomEvent(REQUEST_EVENT, {
detail: { requestId, binding },
}),
);
});
}
/** Install a same-origin token broker in the top YouTube document.
* WebABR may keep running in its legacy /embed player; only PO-token minting
* is delegated to the top player document where Safari exposes the provider.
*/
export function initSafariPagePoTokenBroker(): void {
if (!isSafariBrowser() || window !== window.top || topBrokerInstalled) return;
topBrokerInstalled = true;
addEventListener("message", (event: MessageEvent<TopPoTokenMessage>) => {
const data = event.data;
if (event.origin !== location.origin) return;
if (data?.type !== TOP_REQUEST_MESSAGE) return;
if (
typeof data.requestId !== "string" ||
typeof data.binding !== "string" ||
!data.binding
)
return;
const source = event.source as Window | null;
if (!source) return;
const controller = new AbortController();
void requestSafariPagePoTokenLocal(data.binding, controller.signal)
.then((token) => {
source.postMessage(
{ type: TOP_RESPONSE_MESSAGE, requestId: data.requestId, token },
event.origin,
);
})
.catch((error) => {
source.postMessage(
{
type: TOP_RESPONSE_MESSAGE,
requestId: data.requestId,
error: error instanceof Error ? error.message : String(error),
},
event.origin,
);
});
});
debug.log("[VOT][PO_TOKEN] top player token broker installed");
}
async function requestTopSafariPagePoToken(
binding: string,
signal: AbortSignal,
): Promise<string | undefined> {
const requestId = crypto.randomUUID();
debug.log("[VOT][PO_TOKEN] requesting token from top YouTube player", {
bindingLength: binding.length,
});
return await new Promise<string | undefined>((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => finish(undefined), 8000);
const cleanup = () => {
clearTimeout(timeout);
removeEventListener("message", onMessage);
signal.removeEventListener("abort", onAbort);
};
const finish = (value: string | undefined) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(signal.reason);
};
const onMessage = (event: MessageEvent<TopPoTokenMessage>) => {
if (event.source !== window.top || event.origin !== location.origin)
return;
const data = event.data;
if (data?.type !== TOP_RESPONSE_MESSAGE || data.requestId !== requestId)
return;
if (typeof data.token === "string" && data.token) {
debug.log("[VOT][PO_TOKEN] token received from top YouTube player", {
tokenLength: data.token.length,
});
finish(data.token);
} else {
if (data.error)
debug.error("[VOT][PO_TOKEN] top player token mint failed", {
message: data.error,
});
finish(undefined);
}
};
addEventListener("message", onMessage);
signal.addEventListener("abort", onAbort, { once: true });
window.top?.postMessage(
{ type: TOP_REQUEST_MESSAGE, requestId, binding },
location.origin,
);
});
}
export async function requestSafariPagePoToken(
binding: string,
signal: AbortSignal,
): Promise<string | undefined> {
if (!isSafariBrowser()) return undefined;
signal.throwIfAborted();
if (window !== window.top) {
return requestTopSafariPagePoToken(binding, signal);
}
return requestSafariPagePoTokenLocal(binding, signal);
}

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,16 @@
import { AudioDownloadType } from "@vot.js/core/types/yandex";
import type { GetAudioFromAPIOptions } from "../../types/audioDownloader";
import "./mseProxyHandler";
import debug from "../../utils/debug";
import { makeAbortError } from "../../utils/errors";
import type { AudioChunk } from "./audioChunks";
import type { AudioBridgeStrategy } from "./audioStrategy";
const MESSAGE_TYPE = "get-audio-chunks-by-mse-in-main-world";
export const STREAM_TIMEOUT_MS = 30 * 60_000;
const MESSAGE_TIMEOUT_MS = 2 * 60_000;
const MESSAGE_TIMEOUT_MS = 5 * 60_000;
export type MseProxyChunk = {
buffer: Uint8Array;
isLastChunk: boolean;
};
export function parseMseProxyChunk(payload: unknown): MseProxyChunk {
export function parseAudioBridgeChunk(payload: unknown): AudioChunk {
if (!payload || typeof payload !== "object" || !("buffer" in payload)) {
throw new Error("Audio downloader. Invalid MSE chunk");
throw new Error("Audio downloader. Invalid audio bridge chunk");
}
const { buffer, isLastChunk } = payload as {
@ -32,23 +27,26 @@ export function parseMseProxyChunk(payload: unknown): MseProxyChunk {
: null;
if (!bytes || typeof isLastChunk !== "boolean") {
throw new Error("Audio downloader. Invalid MSE chunk");
throw new Error("Audio downloader. Invalid audio bridge chunk");
}
return { buffer: bytes, isLastChunk };
}
async function* getMseProxyChunks(
async function* getAudioBridgeChunks(
videoId: string,
signal: AbortSignal,
): AsyncGenerator<MseProxyChunk> {
if (signal.aborted) throw new Error(String(signal.reason ?? "Aborted"));
audioDownloadType: AudioBridgeStrategy,
sourceLanguage?: string,
): AsyncGenerator<AudioChunk> {
if (signal.aborted) throw makeAbortError(signal.reason);
const messageId = `stream-message-id-${performance.now()}-${Math.random()}`;
const chunks: MseProxyChunk[] = [];
const chunks: AudioChunk[] = [];
let wake: (() => void) | undefined;
let streamFinished = false;
let failure: Error | undefined;
let receivedChunks = 0;
let messageTimeout: ReturnType<typeof setTimeout>;
const notify = () => {
@ -59,19 +57,41 @@ async function* getMseProxyChunks(
if (error) {
if (failure) return;
failure = error;
debug.error("Audio downloader. Audio bridge failed", {
videoId,
sourceLanguage,
messageId,
audioDownloadType,
receivedChunks,
error: error.message,
});
} else {
streamFinished = true;
clearTimeout(messageTimeout);
debug.log("Audio downloader. Audio bridge stream finished", {
videoId,
sourceLanguage,
messageId,
audioDownloadType,
receivedChunks,
});
}
notify();
};
const resetMessageTimeout = () => {
clearTimeout(messageTimeout);
messageTimeout = setTimeout(
() => finish(new Error("MSE proxy message timed out")),
() => finish(new Error("Audio bridge message timed out")),
MESSAGE_TIMEOUT_MS,
);
};
const throwIfFailed = () => {
if (!failure) return;
if (!globalThis.location.href.includes(videoId)) {
throw makeAbortError("URL changed during audio download");
}
throw failure;
};
const postAbort = () =>
globalThis.postMessage(
{
@ -85,13 +105,14 @@ async function* getMseProxyChunks(
);
const onMessage = (event: MessageEvent) => {
const message = event.data;
const iframe = document.getElementById(
`vot-mse-proxy-${messageId}`,
) as HTMLIFrameElement | null;
// Firefox/Safari userscript realms can expose the same page Window through
// a different WindowProxy/wrapper, so event.source identity is not reliable.
// Authenticate bridge responses by same-origin delivery plus the per-request
// random messageId and protocol fields instead.
const expectedOrigin = globalThis.location.origin;
if (
!message ||
(event.source !== (globalThis as unknown as Window) &&
event.source !== iframe?.contentWindow) ||
event.origin !== expectedOrigin ||
message.messageId !== messageId ||
message.messageType !== MESSAGE_TYPE ||
message.messageDirection !== "response"
@ -100,12 +121,16 @@ async function* getMseProxyChunks(
}
resetMessageTimeout();
if (message.error || message.isAborted) {
if (message.isAborted) {
finish(makeAbortError(message.error));
return;
}
if (message.error) {
finish(
new Error(
typeof message.error === "string"
? message.error
: "MSE proxy stream aborted",
: "Audio bridge failed",
),
);
return;
@ -114,22 +139,40 @@ async function* getMseProxyChunks(
finish();
return;
}
if (message.isProgress) {
debug.log("Audio downloader. Audio bridge progress", {
videoId,
messageId,
audioDownloadType,
});
return;
}
try {
chunks.push(parseMseProxyChunk(message.payload));
const chunk = parseAudioBridgeChunk(message.payload);
chunks.push(chunk);
receivedChunks++;
debug.log("Audio downloader. Audio bridge chunk received", {
videoId,
messageId,
audioDownloadType,
index: receivedChunks - 1,
size: chunk.buffer.byteLength,
isLastChunk: chunk.isLastChunk,
});
notify();
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
};
const onAbort = () => finish(new Error(String(signal.reason ?? "Aborted")));
const onAbort = () => finish(makeAbortError(signal.reason));
const streamTimeout = setTimeout(
() => finish(new Error("MSE proxy stream timed out")),
() => finish(new Error("Audio bridge stream timed out")),
STREAM_TIMEOUT_MS,
);
const navigationInterval = setInterval(() => {
if (!globalThis.location.href.includes(videoId)) {
finish(new Error("URL changed during MSE proxy download"));
finish(makeAbortError("URL changed during audio download"));
}
}, 100);
@ -138,6 +181,13 @@ async function* getMseProxyChunks(
if (signal.aborted) onAbort();
resetMessageTimeout();
debug.log("Audio downloader. Audio bridge request started", {
videoId,
sourceLanguage,
messageId,
audioDownloadType,
});
try {
if (!streamFinished && !failure) {
globalThis.postMessage(
@ -145,14 +195,18 @@ async function* getMseProxyChunks(
messageId,
messageType: MESSAGE_TYPE,
messageDirection: "request",
payload: { pureVideoId: videoId, fromPlayer: true },
payload: {
pureVideoId: videoId,
audioDownloadType,
sourceLanguage,
},
},
"*",
);
}
while (!streamFinished || chunks.length > 0) {
if (failure) throw failure;
throwIfFailed();
const chunk = chunks.shift();
if (chunk) {
yield chunk;
@ -162,7 +216,7 @@ async function* getMseProxyChunks(
});
}
}
if (failure) throw failure;
throwIfFailed();
} finally {
clearTimeout(messageTimeout);
clearTimeout(streamTimeout);
@ -173,13 +227,14 @@ async function* getMseProxyChunks(
}
}
export async function getAudioFromWebMseProxy({
videoId,
signal,
}: GetAudioFromAPIOptions) {
export async function getAudioFromBridge(
{ videoId, signal, sourceLanguage }: GetAudioFromAPIOptions,
audioDownloadType: AudioBridgeStrategy,
) {
return {
fileId: `random-${AudioDownloadType.WEB_MSE_PROXY}-${crypto.randomUUID()}`,
fileId: `random-${audioDownloadType}-${crypto.randomUUID()}`,
mediaPartsLength: null,
getMediaBuffers: () => getMseProxyChunks(videoId, signal),
getMediaBuffers: () =>
getAudioBridgeChunks(videoId, signal, audioDownloadType, sourceLanguage),
};
}

View file

@ -0,0 +1,61 @@
import type { WebAbrWindow } from "./webAbr";
declare const unsafeWindow: WebAbrWindow | undefined;
/** Resolve the real YouTube page realm when the userscript manager exposes it. */
export function getMainWorldWindow(targetWindow: WebAbrWindow): WebAbrWindow {
try {
if (typeof unsafeWindow !== "undefined" && unsafeWindow) {
const unsafe = unsafeWindow as WebAbrWindow;
if (
unsafe.document &&
unsafe.location?.hostname.endsWith("youtube.com")
) {
return unsafe;
}
}
} catch {}
try {
const unsafe = (
globalThis as typeof globalThis & { unsafeWindow?: WebAbrWindow }
).unsafeWindow;
if (unsafe?.document && unsafe.location?.hostname.endsWith("youtube.com")) {
return unsafe;
}
} catch {}
try {
const wrapped = (
targetWindow as WebAbrWindow & { wrappedJSObject?: WebAbrWindow }
).wrappedJSObject;
if (
wrapped?.document &&
wrapped.location?.hostname.endsWith("youtube.com")
) {
return wrapped;
}
} catch {}
return targetWindow;
}
/** Resolve the top same-origin YouTube page while preserving the native page realm. */
export function getTopPageWindow(targetWindow: WebAbrWindow): WebAbrWindow {
const pageWindow = getMainWorldWindow(targetWindow);
try {
const top = pageWindow.top as
| (WebAbrWindow & { wrappedJSObject?: WebAbrWindow })
| null;
if (top?.document && top.location?.hostname.endsWith("youtube.com")) {
try {
return top.wrappedJSObject ?? top;
} catch {
return top;
}
}
} catch {
// Cross-origin frames cannot expose the top page.
}
return pageWindow;
}

View file

@ -0,0 +1,164 @@
import { createAbortableDelay } from "../../utils/abort";
import debug from "../../utils/debug";
import { requestSafariPagePoToken } from "./safariPageBridge";
import type { WebAbrWindow } from "./webAbr";
import { getTopPageWindow } from "./youtubePage";
/** Mint a GVS PO token in the native YouTube page realm when possible. */
export async function mintPagePoToken(
pageWindow: WebAbrWindow,
binding: string,
signal: AbortSignal,
): Promise<string | undefined> {
const mainWindow = getTopPageWindow(pageWindow);
const realms = new Set<WebAbrWindow>([mainWindow]);
if (mainWindow !== pageWindow) realms.add(pageWindow);
try {
realms.add(mainWindow.parent as WebAbrWindow);
realms.add(mainWindow.top as WebAbrWindow);
} catch {
// Cross-origin access is denied.
}
debug.log("[VOT][PO_TOKEN] mint started", {
bindingLength: binding.length,
realmCount: realms.size,
});
for (const realm of realms) {
let keys: string[];
try {
keys = Object.getOwnPropertyNames(realm).filter(
(key) => key === "bevasrsg" || key.startsWith("havuokmhhs-"),
);
} catch {
continue;
}
for (const key of keys) {
let bevasrs: { wpc?: unknown } | undefined;
try {
bevasrs = (
(realm as unknown as Record<string, unknown>)[key] as {
bevasrs?: { wpc?: unknown };
}
)?.bevasrs;
} catch {
continue;
}
const wpc = bevasrs?.wpc;
if (typeof wpc !== "function") continue;
for (let attempt = 0; attempt < 10; attempt++) {
if (signal.aborted) throw signal.reason;
try {
const minter = await wpc.call(bevasrs);
const token = await minter?.mws?.({
c: binding,
mc: false,
me: false,
});
if (typeof token === "string" && token) {
debug.log("[VOT][PO_TOKEN] mint success", {
tokenLength: token.length,
provider: key,
attempt,
});
return token;
}
debug.log("[VOT][PO_TOKEN] mint returned no token", {
provider: key,
attempt,
});
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
debug.log("[VOT][PO_TOKEN] mint attempt failed", {
provider: key,
attempt,
message,
});
if (!message.includes("SDF:notready")) break;
}
await createAbortableDelay(500, signal);
}
}
}
// Safari Userscripts keeps granted scripts outside the true page realm.
const safariToken = await requestSafariPagePoToken(binding, signal);
if (safariToken) {
debug.log("[VOT][PO_TOKEN] Safari page-realm mint success", {
tokenLength: safariToken.length,
});
return safariToken;
}
debug.error("[VOT][PO_TOKEN] mint failed", {
bindingLength: binding.length,
realmCount: realms.size,
});
return undefined;
}
/** Select the binding expected by YouTube's current GVS PO-token policy. */
export function selectGvsPoTokenBinding(
videoId: string,
options: {
loggedIn: boolean;
dataSyncId: unknown;
visitorData: unknown;
experimentFlags: string[];
},
): { kind: "video" | "datasync" | "visitor"; value: string } | undefined {
if (
options.experimentFlags.some(
(flags) =>
new URLSearchParams(flags)
.getAll("html5_generate_content_po_token")
.at(-1) === "true",
)
) {
debug.log("[VOT][WEB_CREATOR] selecting GVS PO binding", {
kind: "video",
loggedIn: options.loggedIn,
hasDataSyncId:
typeof options.dataSyncId === "string" && Boolean(options.dataSyncId),
reason: "html5_generate_content_po_token",
});
return { kind: "video", value: videoId };
}
if (
options.loggedIn &&
typeof options.dataSyncId === "string" &&
options.dataSyncId
) {
debug.log("[VOT][WEB_CREATOR] selecting GVS PO binding", {
kind: "datasync",
loggedIn: true,
hasDataSyncId: true,
});
return { kind: "datasync", value: options.dataSyncId };
}
if (typeof options.visitorData === "string" && options.visitorData) {
debug.log("[VOT][WEB_CREATOR] selecting GVS PO binding", {
kind: "visitor",
loggedIn: options.loggedIn,
hasDataSyncId:
typeof options.dataSyncId === "string" && Boolean(options.dataSyncId),
});
return { kind: "visitor", value: options.visitorData };
}
debug.error("[VOT][WEB_CREATOR] no GVS PO binding available", {
loggedIn: options.loggedIn,
hasDataSyncId:
typeof options.dataSyncId === "string" && Boolean(options.dataSyncId),
hasVisitorData:
typeof options.visitorData === "string" && Boolean(options.visitorData),
});
return undefined;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
import {
findJsonValueEnd,
getConfigValue,
getPlayerUrl,
isDrcAudioFormat,
solveYouTubeChallenges,
validPageValue,
type WebAbrWindow,
type WebEmbeddedFormat,
type WebEmbeddedPlayerResponse,
type YouTubeConfig,
} from "./webAbr";
import { getTopPageWindow } from "./youtubePage";
export { getTopPageWindow } from "./youtubePage";
function readInitialPlayerResponseFromDocument(
targetWindow: WebAbrWindow,
): WebEmbeddedPlayerResponse | undefined {
let scripts: HTMLScriptElement[] = [];
try {
const pageWindow = getTopPageWindow(targetWindow);
scripts = [
...pageWindow.document.querySelectorAll<HTMLScriptElement>(
"script:not([src])",
),
];
} catch {
return undefined;
}
const markers = [
"ytInitialPlayerResponse =",
"ytInitialPlayerResponse=",
'window["ytInitialPlayerResponse"] =',
"window['ytInitialPlayerResponse'] =",
];
for (const script of scripts) {
const source = script.textContent ?? "";
if (!source.includes("ytInitialPlayerResponse")) continue;
for (const marker of markers) {
let cursor = 0;
while (cursor < source.length) {
const markerIndex = source.indexOf(marker, cursor);
if (markerIndex < 0) break;
let start = markerIndex + marker.length;
while (start < source.length && /\s/.test(source[start] ?? "")) start++;
if (source[start] !== "{") {
cursor = start + 1;
continue;
}
const end = findJsonValueEnd(source, start);
if (end < 0) break;
try {
const value = JSON.parse(
source.slice(start, end),
) as WebEmbeddedPlayerResponse;
if (value && typeof value === "object") return value;
} catch {
// Keep looking: pages can contain stale/non-JSON occurrences too.
}
cursor = end;
}
}
}
return undefined;
}
export function getNativePlayerResponse(
targetWindow: WebAbrWindow,
videoId: string,
): WebEmbeddedPlayerResponse | undefined {
const candidates: WebEmbeddedPlayerResponse[] = [];
try {
const pageWindow = getTopPageWindow(targetWindow);
const moviePlayer = pageWindow.document.querySelector("#movie_player") as
| (HTMLElement & { getPlayerResponse?: () => unknown })
| null;
if (moviePlayer && typeof moviePlayer.getPlayerResponse === "function") {
const response = moviePlayer.getPlayerResponse();
if (response && typeof response === "object") {
candidates.push(response as WebEmbeddedPlayerResponse);
}
}
} catch {}
try {
if (targetWindow.ytInitialPlayerResponse) {
candidates.push(targetWindow.ytInitialPlayerResponse);
}
} catch {}
const documentResponse = readInitialPlayerResponseFromDocument(targetWindow);
if (documentResponse) candidates.push(documentResponse);
for (const value of candidates) {
const responseVideoId = value.videoDetails?.videoId;
if (responseVideoId && responseVideoId !== videoId) continue;
if (
value.streamingData?.serverAbrStreamingUrl &&
value.playerConfig?.mediaCommonConfig?.mediaUstreamerRequestConfig
?.videoPlaybackUstreamerConfig &&
value.streamingData?.adaptiveFormats?.length
) {
return value;
}
}
}
export function selectEconomyAudioFormat(
formats: WebEmbeddedFormat[],
): WebEmbeddedFormat | undefined {
const audioOnly = formats.filter(
(format) =>
typeof format.itag === "number" &&
format.mimeType?.includes("audio/") &&
!format.mimeType?.includes("video/"),
);
const nonDrc = audioOnly.filter((format) => !isDrcAudioFormat(format));
const candidates = nonDrc.length > 0 ? nonDrc : audioOnly;
const contentLength = (format: WebEmbeddedFormat) => {
const value = Number(format.contentLength);
return Number.isFinite(value) && value > 0
? value
: Number.POSITIVE_INFINITY;
};
const bitrate = (format: WebEmbeddedFormat) => {
const value = Number(format.averageBitrate ?? format.bitrate);
return Number.isFinite(value) && value > 0
? value
: Number.POSITIVE_INFINITY;
};
return [...candidates].sort((a, b) => {
const sizeDiff = contentLength(a) - contentLength(b);
if (Number.isFinite(sizeDiff) && sizeDiff !== 0) return sizeDiff;
const bitrateDiff = bitrate(a) - bitrate(b);
if (Number.isFinite(bitrateDiff) && bitrateDiff !== 0) return bitrateDiff;
return (
(a.itag ?? Number.MAX_SAFE_INTEGER) - (b.itag ?? Number.MAX_SAFE_INTEGER)
);
})[0];
}
const N_PATTERN = /^[A-Za-z0-9_-]{4,}$/;
export async function resolveSabrStreamingUrl(
targetWindow: WebAbrWindow,
rawUrl: string,
config: YouTubeConfig,
signal: AbortSignal,
): Promise<string> {
signal.throwIfAborted();
const url = new URL(rawUrl);
const n = url.searchParams.get("n") ?? undefined;
if (!n) return url.toString();
const playerUrl = getPlayerUrl(config);
if (!playerUrl) {
throw new Error(
"Audio downloader. YouTube player URL is unavailable for SABR n solve",
);
}
const response = await targetWindow.fetch(playerUrl, { signal });
if (!response.ok) {
throw new Error(
`Audio downloader. YouTube player request failed (${response.status})`,
);
}
const code = await response.text();
signal.throwIfAborted();
const raw = solveYouTubeChallenges(targetWindow, code, undefined, n);
const solvedN = validPageValue(raw.n, n, N_PATTERN);
if (!solvedN) {
throw new Error("Audio downloader. SABR n challenge solve invalid");
}
url.searchParams.set("n", solvedN);
return url.toString();
}
function buildContentPlaybackContext(
signatureTimestamp: unknown,
): Record<string, unknown> {
const context: Record<string, unknown> = {
html5Preference: "HTML5_PREF_WANTS",
};
const timestamp = Number(signatureTimestamp);
if (Number.isFinite(timestamp) && timestamp > 0) {
context.signatureTimestamp = timestamp;
}
return context;
}
export function buildSabrPlayerRequest(
config: YouTubeConfig,
videoId: string,
extractedSignatureTimestamp?: number,
): Record<string, unknown> {
const rawContext = getConfigValue(config, "INNERTUBE_CONTEXT");
if (!rawContext || typeof rawContext !== "object") {
throw new Error("Audio downloader. web client context is unavailable");
}
const context = JSON.parse(JSON.stringify(rawContext)) as {
client?: Record<string, unknown>;
thirdParty?: Record<string, unknown>;
};
context.client ??= {};
const client = context.client;
client.clientName = "WEB";
client.clientVersion =
getConfigValue(config, "INNERTUBE_CLIENT_VERSION") ?? client.clientVersion;
client.originalUrl = `https://www.youtube.com/watch?v=${videoId}`;
delete context.thirdParty;
return {
context,
videoId,
playbackContext: {
contentPlaybackContext: buildContentPlaybackContext(
extractedSignatureTimestamp ?? getConfigValue(config, "STS"),
),
},
contentCheckOk: true,
racyCheckOk: true,
};
}

View file

@ -0,0 +1,110 @@
import { createAbortableDelay } from "../../utils/abort";
import debug from "../../utils/debug";
import type { AudioChunk } from "./audioChunks";
import type { WebAbrWindow } from "./webAbr";
import { trySabrAudioChunks } from "./youtubeSabr";
const SABR_MAX_ATTEMPTS = 2;
const SABR_RETRY_DELAY_MS = 750;
const SABR_DOWNLOAD_QUEUE = new Map<string, Promise<void>>();
function isDeterministicSabrError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("SABR could not resolve requested audio language") ||
message.includes("SABR found no VOT-supported audio track") ||
(message.includes("SABR audio language") &&
message.includes("is ambiguous")) ||
message.includes("refusing array[0] fallback")
);
}
async function* getSabrAudioChunksImpl(
targetWindow: WebAbrWindow,
videoId: string,
signal: AbortSignal,
sourceLanguage?: string,
): AsyncGenerator<AudioChunk> {
let lastError: unknown;
for (let attempt = 1; attempt <= SABR_MAX_ATTEMPTS; attempt++) {
const chunks: AudioChunk[] = [];
let bytes = 0;
try {
for await (const chunk of trySabrAudioChunks(
targetWindow,
videoId,
signal,
sourceLanguage,
)) {
const buffer = chunk.buffer.slice();
chunks.push({ buffer, isLastChunk: chunk.isLastChunk });
bytes += buffer.byteLength;
}
if (!chunks.length)
throw new Error(
"Audio downloader. SABR completed without audio chunks",
);
debug.log("Audio downloader. SABR completed", {
videoId,
attempt,
chunks: chunks.length,
bytes,
});
yield* chunks;
return;
} catch (error) {
signal.throwIfAborted();
lastError = error;
debug.log("Audio downloader. SABR attempt failed", {
videoId,
attempt,
bufferedChunks: chunks.length,
bufferedBytes: bytes,
error: error instanceof Error ? error.message : String(error),
});
if (attempt < SABR_MAX_ATTEMPTS && !isDeterministicSabrError(error)) {
await createAbortableDelay(SABR_RETRY_DELAY_MS, signal);
continue;
}
break;
}
}
throw lastError instanceof Error
? lastError
: new Error(String(lastError ?? "Audio downloader. SABR failed"));
}
export async function* getSabrAudioChunks(
targetWindow: WebAbrWindow,
videoId: string,
signal: AbortSignal,
sourceLanguage?: string,
): AsyncGenerator<AudioChunk> {
const queueKey = String(videoId);
const previous = SABR_DOWNLOAD_QUEUE.get(queueKey) ?? Promise.resolve();
const hadPrevious = SABR_DOWNLOAD_QUEUE.has(queueKey);
let releaseCurrent: (() => void) | undefined;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
SABR_DOWNLOAD_QUEUE.set(queueKey, current);
debug.log("Audio downloader. SABR queued", {
videoId,
hasPrevious: hadPrevious,
});
try {
await previous;
signal.throwIfAborted();
yield* getSabrAudioChunksImpl(
targetWindow,
videoId,
signal,
sourceLanguage,
);
} finally {
releaseCurrent?.();
if (SABR_DOWNLOAD_QUEUE.get(queueKey) === current)
SABR_DOWNLOAD_QUEUE.delete(queueKey);
debug.log("Audio downloader. SABR queue released", { videoId });
}
}

View file

@ -0,0 +1,829 @@
import type { SabrFormat } from "googlevideo/shared-types";
import { createAbortableDelay } from "../../utils/abort";
import debug from "../../utils/debug";
import {
getYoutubeAudioFormatLanguage as getAudioFormatLanguage,
normalizeAudioLanguageTag as normalizeAudioLanguage,
} from "../utils";
import {
audioLanguageMatches,
isDrcAudioFormat,
type WebAbrWindow,
type WebEmbeddedFormat,
} from "./webAbr";
import { getTopPageWindow } from "./youtubePage";
export function toSabrFormat(
format: WebEmbeddedFormat,
): SabrFormat | undefined {
const itag = Number(format.itag);
const bitrate = Number(format.bitrate ?? format.averageBitrate);
const approxDurationMs = Number(format.approxDurationMs);
const lastModified = String(format.lastModified ?? "");
if (!(itag > 0) || !(bitrate > 0) || !(approxDurationMs > 0) || !lastModified)
return;
const language = getAudioFormatLanguage(format) || undefined;
return {
itag,
lastModified,
xtags: format.xtags,
width: format.width,
height: format.height,
contentLength: Number(format.contentLength) || undefined,
audioTrackId: format.audioTrackId ?? format.audioTrack?.id,
mimeType: format.mimeType,
isDrc: isDrcAudioFormat(format),
quality: format.quality,
qualityLabel: format.qualityLabel,
averageBitrate: Number(format.averageBitrate) || undefined,
bitrate,
audioQuality: format.audioQuality,
approxDurationMs,
language,
isOriginal: format.audioTrack?.audioIsDefault === true,
};
}
function encodeProtoVarint(value: number): Uint8Array {
const out: number[] = [];
let current = Math.max(0, Math.floor(value));
do {
let byte = current % 128;
current = Math.floor(current / 128);
if (current > 0) byte |= 0x80;
out.push(byte);
} while (current > 0);
return new Uint8Array(out);
}
function concatBytes(parts: Uint8Array[]): Uint8Array {
const size = parts.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(size);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.byteLength;
}
return out;
}
function readProtoVarintAt(
bytes: Uint8Array,
offset: number,
): { value: number; next: number } {
let value = 0;
let shift = 0;
let cursor = offset;
while (cursor < bytes.length && shift <= 49) {
const byte = bytes[cursor++];
value += (byte & 0x7f) * 2 ** shift;
if ((byte & 0x80) === 0) return { value, next: cursor };
shift += 7;
}
throw new Error("invalid protobuf varint");
}
function splitTopLevelProto(
bytes: Uint8Array,
): Array<{ field: number; start: number; end: number }> {
const out: Array<{ field: number; start: number; end: number }> = [];
let offset = 0;
while (offset < bytes.length) {
const start = offset;
const tag = readProtoVarintAt(bytes, offset);
offset = tag.next;
const field = Math.floor(tag.value / 8);
const wire = tag.value & 7;
if (wire === 0) offset = readProtoVarintAt(bytes, offset).next;
else if (wire === 1) offset += 8;
else if (wire === 2) {
const length = readProtoVarintAt(bytes, offset);
offset = length.next + length.value;
} else if (wire === 5) offset += 4;
else throw new Error(`unsupported protobuf wire ${wire}`);
if (offset > bytes.length) throw new Error("truncated protobuf field");
out.push({ field, start, end: offset });
}
return out;
}
/**
* SABR-only: set ClientAbrState.audioTrackId (nested field 69 inside
* VideoPlaybackAbrRequest field 1). We keep SabrStream's generated ABR state
* authoritative and only add/replace the concrete YouTube audio track id.
*
* Native requests observed on multi-audio videos carry values such as
* "ar.10", "de-DE.10" and "en-US.4" in this field.
*/
export function setGeneratedSabrAudioTrackId(
generated: Uint8Array,
trackId: string,
): Uint8Array {
const topLevel = splitTopLevelProto(generated);
const encodedTrackId = new TextEncoder().encode(trackId);
const trackField = concatBytes([
encodeProtoVarint((69 << 3) | 2),
encodeProtoVarint(encodedTrackId.byteLength),
encodedTrackId,
]);
const chunks: Uint8Array[] = [];
let replacedClientAbrState = false;
for (const part of topLevel) {
if (part.field !== 1 || replacedClientAbrState) {
chunks.push(generated.slice(part.start, part.end));
continue;
}
const tag = readProtoVarintAt(generated, part.start);
const length = readProtoVarintAt(generated, tag.next);
const payloadStart = length.next;
const payloadEnd = payloadStart + length.value;
if (payloadEnd > part.end) throw new Error("truncated ClientAbrState");
const payload = generated.slice(payloadStart, payloadEnd);
const fields = splitTopLevelProto(payload);
const payloadChunks: Uint8Array[] = fields
.filter((field) => field.field !== 69)
.map((field) => payload.slice(field.start, field.end));
payloadChunks.push(trackField);
const patchedPayload = concatBytes(payloadChunks);
chunks.push(
concatBytes([
encodeProtoVarint((1 << 3) | 2),
encodeProtoVarint(patchedPayload.byteLength),
patchedPayload,
]),
);
replacedClientAbrState = true;
}
if (!replacedClientAbrState) {
throw new Error("SABR ClientAbrState field is unavailable");
}
return concatBytes(chunks);
}
type YouTubeRuntimeAudioTrack = {
id?: string;
xtags?: string;
wM?: {
id?: string;
name?: string;
isDefault?: boolean;
isAutoDubbed?: boolean;
getId?: () => string;
getName?: () => string;
};
captionTracks?: Array<{ kind?: string; languageCode?: string }>;
};
function getYouTubeRuntimeAudioTracks(
targetWindow: WebAbrWindow,
): YouTubeRuntimeAudioTrack[] {
try {
const pageWindow = getTopPageWindow(targetWindow);
const player = pageWindow.document.querySelector("#movie_player") as
| (HTMLElement & { getAvailableAudioTracks?: () => unknown })
| null;
const value = player?.getAvailableAudioTracks?.();
return Array.isArray(value)
? value.filter((track): track is YouTubeRuntimeAudioTrack =>
Boolean(track && typeof track === "object"),
)
: [];
} catch {
return [];
}
}
function getRuntimeAudioTrackId(
track: YouTubeRuntimeAudioTrack,
): string | undefined {
let methodId: string | undefined;
try {
methodId = track.wM?.getId?.();
} catch {}
const id = typeof methodId === "string" ? methodId : track.wM?.id;
return typeof id === "string" &&
/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?\.\d{1,3}$/.test(id)
? id
: undefined;
}
type YouTubeSabrTrackDiscovery = {
trackId: string;
raw?: object;
source: string;
};
function collectYouTubeSabrTrackDiscoveries(
targetWindow: WebAbrWindow,
): YouTubeSabrTrackDiscovery[] {
const pageWindow = getTopPageWindow(targetWindow);
const roots: Array<{ value: unknown; source: string }> = [];
const addRoot = (value: unknown, source: string) => {
if (value != null) roots.push({ value, source });
};
try {
addRoot(pageWindow.ytInitialPlayerResponse, "ytInitialPlayerResponse");
} catch {
// Ignore sandbox visibility failures.
}
try {
const player = pageWindow.document.querySelector("#movie_player") as
| (HTMLElement &
Record<string, unknown> & {
getPlayerResponse?: () => unknown;
getAudioTrack?: () => unknown;
getAudioTrackList?: () => unknown;
getAvailableAudioTracks?: () => unknown;
getOption?: (namespace: string, key: string) => unknown;
})
| null;
if (player) {
for (const getter of [
"getPlayerResponse",
"getAudioTrack",
"getAudioTrackList",
"getAvailableAudioTracks",
] as const) {
try {
const fn = player[getter];
if (typeof fn === "function") {
addRoot(fn.call(player), `player.${getter}()`);
}
} catch {
// Private player APIs vary by WEB/MWEB build.
}
}
// This is important for multi-audio YouTube videos. On current WEB/MWEB
// builds the concrete ids (ar.10, de-DE.10, en-US.4, ...) can be absent
// from /player adaptiveFormats and the public-ish audio getters, while
// still being exposed through getOption/internal player state.
if (typeof player.getOption === "function") {
for (const [namespace, key] of [
["audio", "tracklist"],
["audio", "track"],
] as const) {
try {
addRoot(
player.getOption(namespace, key),
`player.getOption(${namespace},${key})`,
);
} catch {
// Keep scanning other runtime state.
}
}
}
for (const key of [
"playerData",
"playerResponse",
"audioTracks",
"tracklist",
"config",
] as const) {
try {
addRoot(player[key], `player.${key}`);
} catch {
// Some private properties can throw through page wrappers.
}
}
}
} catch {
// Ignore inaccessible page DOM.
}
const trackIdPattern = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?\.\d{1,3}$/;
const found = new Map<string, YouTubeSabrTrackDiscovery>();
const seen = new WeakSet<object>();
let visited = 0;
const walk = (
value: unknown,
depth: number,
path: string,
source: string,
) => {
if (value == null || depth > 9 || visited > 30000) return;
if (typeof value === "string") {
if (trackIdPattern.test(value) && /audio/i.test(`${source}:${path}`)) {
found.set(`${value}|${source}|${path}`, {
trackId: value,
source: `${source}:${path}`,
});
}
return;
}
if (typeof value !== "object") return;
if (seen.has(value as object)) return;
seen.add(value as object);
visited += 1;
const record = value as Record<string, unknown>;
for (const key of ["trackId", "audioTrackId", "id"] as const) {
const id = record[key];
if (
typeof id === "string" &&
trackIdPattern.test(id) &&
(/audio/i.test(`${source}:${path}`) || key === "audioTrackId")
) {
found.set(`${id}|${source}|${path}`, {
trackId: id,
raw: value as object,
source: `${source}:${path}`,
});
}
}
const nestedAudioTrack = record.audioTrack;
if (nestedAudioTrack && typeof nestedAudioTrack === "object") {
const nestedId = (nestedAudioTrack as Record<string, unknown>).id;
if (typeof nestedId === "string" && trackIdPattern.test(nestedId)) {
found.set(`${nestedId}|${source}|${path}.audioTrack`, {
trackId: nestedId,
raw: nestedAudioTrack as object,
source: `${source}:${path}.audioTrack`,
});
}
}
let entries: [string, unknown][];
try {
entries = Array.isArray(value)
? value.slice(0, 400).map((child, index) => [String(index), child])
: Object.entries(record).slice(0, 400);
} catch {
return;
}
for (const [key, child] of entries) {
if (typeof child === "function") continue;
walk(child, depth + 1, path ? `${path}.${key}` : key, source);
}
};
for (const root of roots) walk(root.value, 0, "", root.source);
return [...found.values()];
}
function collectYouTubeSabrTrackIds(targetWindow: WebAbrWindow): string[] {
const runtimeIds = getYouTubeRuntimeAudioTracks(targetWindow)
.map(getRuntimeAudioTrackId)
.filter((id): id is string => Boolean(id));
if (runtimeIds.length > 0) return [...new Set(runtimeIds)].sort();
return [
...new Set(
collectYouTubeSabrTrackDiscoveries(targetWindow).map(
(entry) => entry.trackId,
),
),
].sort();
}
export function switchYouTubeSabrAudioTrack(
targetWindow: WebAbrWindow,
trackId: string,
): boolean {
const pageWindow = getTopPageWindow(targetWindow);
try {
const player = pageWindow.document.querySelector("#movie_player") as
| (HTMLElement & {
getAudioTrack?: () => unknown;
setAudioTrack?: (track: unknown) => unknown;
})
| null;
if (!player || typeof player.setAudioTrack !== "function") return false;
const exact = getYouTubeRuntimeAudioTracks(targetWindow).find(
(track) => getRuntimeAudioTrackId(track) === trackId,
);
if (!exact) return false;
const current = player.getAudioTrack?.() as
| YouTubeRuntimeAudioTrack
| undefined;
if (current && getRuntimeAudioTrackId(current) === trackId) return true;
player.setAudioTrack(exact);
return true;
} catch {
return false;
}
}
/**
* Languages that SABR may use as an alternative source audio track.
*
* Keep this list local on purpose: alternative SABR selection must not expand
* automatically when the global VOT language list changes.
*
* Visible as supported source languages in the supplied VOT UI:
* Russian, English, Chinese, Korean, French, Italian, Spanish, German, Japanese.
*
* Add any other explicitly allowed source language here by its BCP-47/base code.
*/
const SABR_ALTERNATIVE_SOURCE_LANGUAGES = [
"ru",
"en",
"zh",
"ko",
"fr",
"it",
"es",
"de",
"ja",
] as const;
const VOT_SUPPORTED_SABR_SOURCE_LANGUAGES = new Set<string>(
SABR_ALTERNATIVE_SOURCE_LANGUAGES.map((language) =>
normalizeAudioLanguage(language),
).filter(Boolean),
);
export function sabrTrackLanguage(trackId: string): string {
return normalizeAudioLanguage(trackId.replace(/\.\d+$/, ""));
}
function isVotSupportedSabrTrack(trackId: string): boolean {
const language = sabrTrackLanguage(trackId);
if (!language) return false;
if (VOT_SUPPORTED_SABR_SOURCE_LANGUAGES.has(language)) return true;
const base = language.split("-")[0];
return [...VOT_SUPPORTED_SABR_SOURCE_LANGUAGES].some(
(supported) => supported.split("-")[0] === base,
);
}
type CurrentYouTubeAudioEvidence = {
language?: string;
trackId?: string;
representationId?: string;
source: "track-id" | "track-language" | "default-asr" | "unknown";
isDefault?: boolean;
isAutoDubbed?: boolean;
asrLanguages: string[];
};
function isVotSupportedAudioLanguage(language: string | undefined): boolean {
const normalized = normalizeAudioLanguage(language);
if (!normalized) return false;
if (VOT_SUPPORTED_SABR_SOURCE_LANGUAGES.has(normalized)) return true;
const base = normalized.split("-")[0];
return [...VOT_SUPPORTED_SABR_SOURCE_LANGUAGES].some(
(supported) => supported.split("-")[0] === base,
);
}
/**
* Read the audio track that the native YouTube player is actually playing.
*
* Ordinary single-audio videos frequently expose the track itself as `und` and
* leave adaptiveFormats language-less. In that case YouTube still attaches its
* ASR captions to getAudioTrack().captionTracks. A single ASR language on the
* default, non-auto-dubbed track is strong evidence for the language of the
* original audio (e.g. und + Default + ASR pt => Portuguese original).
*
* Be deliberately conservative: translated/manual captions are never used as
* audio-language evidence, and ambiguous ASR languages produce no decision.
*/
function getCurrentYouTubeAudioEvidence(
targetWindow: WebAbrWindow,
): CurrentYouTubeAudioEvidence {
try {
const pageWindow = getTopPageWindow(targetWindow);
const moviePlayer = pageWindow.document.querySelector("#movie_player") as
| (HTMLElement & { getAudioTrack?: () => unknown })
| null;
if (!moviePlayer || typeof moviePlayer.getAudioTrack !== "function") {
return { source: "unknown", asrLanguages: [] };
}
const raw = moviePlayer.getAudioTrack() as
| {
id?: unknown;
language?: unknown;
languageCode?: unknown;
xtags?: unknown;
wM?: {
id?: unknown;
language?: unknown;
languageCode?: unknown;
isDefault?: unknown;
isAutoDubbed?: unknown;
};
captionTracks?: Array<{
kind?: unknown;
languageCode?: unknown;
}>;
}
| undefined;
if (!raw) return { source: "unknown", asrLanguages: [] };
const isDefault = raw.wM?.isDefault === true;
const isAutoDubbed = raw.wM?.isAutoDubbed === true;
const representationId =
typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : undefined;
const concreteTrackId =
typeof raw.wM?.id === "string" &&
/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?\.\d{1,3}$/.test(raw.wM.id.trim())
? raw.wM.id.trim()
: undefined;
// Only wM.id is a logical YouTube audioTrackId. raw.id is a media
// representation identity (for example `251;<attrs>`) and must never be
// fed back into ClientAbrState.audioTrackId.
if (concreteTrackId) {
return {
language: sabrTrackLanguage(concreteTrackId),
trackId: concreteTrackId,
representationId,
source: "track-id",
isDefault,
isAutoDubbed,
asrLanguages: [],
};
}
const directLanguage = [
raw.languageCode,
raw.language,
raw.wM?.languageCode,
raw.wM?.language,
]
.filter((value): value is string => typeof value === "string")
.map(normalizeAudioLanguage)
.find((value) => value && value !== "und" && value !== "auto");
if (directLanguage) {
return {
language: directLanguage,
trackId: concreteTrackId,
representationId,
source: "track-language",
isDefault,
isAutoDubbed,
asrLanguages: [],
};
}
const asrLanguages = [
...new Set(
(raw.captionTracks ?? [])
.filter((caption) => caption?.kind === "asr")
.map((caption) =>
typeof caption.languageCode === "string"
? normalizeAudioLanguage(caption.languageCode)
: "",
)
.filter(
(language) => language && language !== "und" && language !== "auto",
),
),
];
// Only infer from ASR for the ordinary original/default track. Auto-dubbed
// tracks must have their own concrete language evidence instead.
if (isDefault && !isAutoDubbed && asrLanguages.length === 1) {
return {
language: asrLanguages[0],
trackId: concreteTrackId,
representationId,
source: "default-asr",
isDefault,
isAutoDubbed,
asrLanguages,
};
}
return {
trackId: concreteTrackId,
representationId,
source: "unknown",
isDefault,
isAutoDubbed,
asrLanguages,
};
} catch {
return { source: "unknown", asrLanguages: [] };
}
}
export function assertYouTubeAudioIsUsefulForVot(
targetWindow: WebAbrWindow,
videoId: string,
requestedLanguage?: string,
): void {
const evidence = getCurrentYouTubeAudioEvidence(targetWindow);
const actual = normalizeAudioLanguage(evidence.language);
const requested = normalizeAudioLanguage(requestedLanguage);
debug.log("Audio downloader. current YouTube audio evidence", {
videoId,
requestedLanguage: requested || undefined,
actualLanguage: actual || undefined,
trackId: evidence.trackId,
representationId: evidence.representationId,
source: evidence.source,
isDefault: evidence.isDefault,
isAutoDubbed: evidence.isAutoDubbed,
asrLanguages: evidence.asrLanguages,
});
if (!actual) return;
const runtimeTrackIds = getYouTubeRuntimeAudioTracks(targetWindow)
.map(getRuntimeAudioTrackId)
.filter((id): id is string => Boolean(id));
const requestedConcreteTrack =
requested && requested !== "auto"
? runtimeTrackIds.find((id) =>
audioLanguageMatches(sabrTrackLanguage(id), requested),
)
: undefined;
const supportedAlternate = runtimeTrackIds.find(isVotSupportedSabrTrack);
if (!isVotSupportedAudioLanguage(actual)) {
// A concrete requested language may only be satisfied by that language.
// An arbitrary supported alternate is valid only for auto/no-language mode.
if (requested && requested !== "auto") {
if (requestedConcreteTrack) return;
} else if (supportedAlternate) {
return;
}
throw new Error(
`Audio downloader. refusing audio download/upload: native YouTube audio language ${actual} ` +
`is not supported by VOT/Yandex and no supported YouTube alternate audio track is available ` +
`(evidence: ${evidence.source})`,
);
}
if (
requested &&
requested !== "auto" &&
!audioLanguageMatches(actual, requested)
) {
if (requestedConcreteTrack) return;
throw new Error(
`Audio downloader. refusing audio download/upload: requested ${requested}, but native YouTube audio is ${actual} ` +
`and no matching YouTube audio track is available (evidence: ${evidence.source})`,
);
}
}
export type WebAbrResolvedAudioLanguage = {
videoId: string;
requestedLanguage?: string;
actualLanguage?: string;
trackId?: string;
selection:
| "ui-language"
| "supported-alternative"
| "native-only"
| "native-auto";
};
export const WEB_ABR_RESOLVED_AUDIO_LANGUAGES = new Map<
string,
WebAbrResolvedAudioLanguage
>();
export function getWebAbrResolvedAudioLanguage(
videoId: string,
): WebAbrResolvedAudioLanguage | undefined {
return WEB_ABR_RESOLVED_AUDIO_LANGUAGES.get(videoId);
}
function resolveSabrAudioTrackId(
targetWindow: WebAbrWindow,
requestedLanguage?: string,
): {
trackId?: string;
availableTrackIds: string[];
supportedAlternativeTrackIds: string[];
usedSupportedAlternative: boolean;
} {
const requested = normalizeAudioLanguage(requestedLanguage);
const availableTrackIds = collectYouTubeSabrTrackIds(targetWindow);
const supportedAlternativeTrackIds = availableTrackIds.filter(
isVotSupportedSabrTrack,
);
if (!requested || requested === "auto") {
return {
availableTrackIds,
supportedAlternativeTrackIds,
usedSupportedAlternative: false,
};
}
if (availableTrackIds.length === 0) {
return {
availableTrackIds,
supportedAlternativeTrackIds,
usedSupportedAlternative: false,
};
}
const exact = availableTrackIds.find(
(trackId) => sabrTrackLanguage(trackId) === requested,
);
if (exact) {
return {
trackId: exact,
availableTrackIds,
supportedAlternativeTrackIds,
usedSupportedAlternative: false,
};
}
const requestedBase = requested.split("-")[0];
const baseMatches = availableTrackIds.filter(
(trackId) => sabrTrackLanguage(trackId).split("-")[0] === requestedBase,
);
if (baseMatches.length === 1) {
return {
trackId: baseMatches[0],
availableTrackIds,
supportedAlternativeTrackIds,
usedSupportedAlternative: false,
};
}
if (baseMatches.length > 1) {
throw new Error(
`Audio downloader. SABR audio language ${requestedLanguage} is ambiguous ` +
`(matches: ${baseMatches.join(", ")})`,
);
}
// The requested YouTube track is absent. webAbr must not choose a different
// translation language. videoManager is responsible for selecting a concrete
// supported source language before calling webAbr.
return {
availableTrackIds,
supportedAlternativeTrackIds,
usedSupportedAlternative: false,
};
}
export async function waitForSabrAudioTrackId(
targetWindow: WebAbrWindow,
signal: AbortSignal,
requestedLanguage?: string,
timeoutMs = 2000,
): Promise<ReturnType<typeof resolveSabrAudioTrackId>> {
const requested = normalizeAudioLanguage(requestedLanguage);
if (!requested || requested === "auto") {
return resolveSabrAudioTrackId(targetWindow, requestedLanguage);
}
const startedAt = Date.now();
let lastAvailable: string[] = [];
while (Date.now() - startedAt < timeoutMs) {
signal.throwIfAborted();
const resolved = resolveSabrAudioTrackId(targetWindow, requestedLanguage);
lastAvailable = resolved.availableTrackIds;
if (resolved.trackId) return resolved;
await createAbortableDelay(250, signal);
}
// YouTube does not expose runtime audio track IDs for ordinary single-audio
// videos. In that case there is no multi-audio choice to enforce, so keep the
// proven native SABR/default-track path. Strict matching is required only once
// YouTube actually exposes one or more concrete multi-audio track IDs.
if (lastAvailable.length === 0) {
return {
availableTrackIds: [],
supportedAlternativeTrackIds: [],
usedSupportedAlternative: false,
};
}
throw new Error(
`Audio downloader. SABR could not resolve requested audio language ${requestedLanguage} ` +
`to a YouTube trackId within ${timeoutMs}ms ` +
`(available: ${lastAvailable.join(", ")})`,
);
}
type VotNativeProtoContext = {
videoId: string;
sabrAudioTrackId?: string;
buildIndex: number;
pageWindow: WebAbrWindow;
};
// Context must belong to a SabrStream INSTANCE, not SabrStream.prototype.
// YouTube SPA navigation can leave the previous stream alive briefly.
export const VOT_SABR_INSTANCE_CONTEXT = new WeakMap<
object,
VotNativeProtoContext
>();

View file

@ -0,0 +1,44 @@
import debug from "../../utils/debug";
import type { AudioChunk } from "./audioChunks";
import {
getWebAbrAudioChunks as getLegacyWebAbrAudioChunks,
type WebAbrWindow,
} from "./webAbr";
const WEB_ABR_DOWNLOAD_QUEUE = new Map<string, Promise<void>>();
/** Pure WebABR strategy. SABR is implemented separately. */
export async function* getWebAbrAudioChunks(
targetWindow: WebAbrWindow,
videoId: string,
signal: AbortSignal,
sourceLanguage?: string,
): AsyncGenerator<AudioChunk> {
const queueKey = String(videoId);
const previous = WEB_ABR_DOWNLOAD_QUEUE.get(queueKey) ?? Promise.resolve();
const hadPrevious = WEB_ABR_DOWNLOAD_QUEUE.has(queueKey);
let releaseCurrent: (() => void) | undefined;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
WEB_ABR_DOWNLOAD_QUEUE.set(queueKey, current);
debug.log("Audio downloader. WebABR queued", {
videoId,
hasPrevious: hadPrevious,
});
try {
await previous;
signal.throwIfAborted();
yield* getLegacyWebAbrAudioChunks(
targetWindow,
videoId,
signal,
sourceLanguage,
);
} finally {
releaseCurrent?.();
if (WEB_ABR_DOWNLOAD_QUEUE.get(queueKey) === current)
WEB_ABR_DOWNLOAD_QUEUE.delete(queueKey);
debug.log("Audio downloader. WebABR queue released", { videoId });
}
}

View file

@ -0,0 +1,273 @@
// @ts-nocheck
// biome-ignore-all lint: Vendored from yt-dlp/ejs 0.8.0 (Unlicense).
/*!
* SPDX-License-Identifier: Unlicense
* Adapted from https://github.com/yt-dlp/ejs
*/
import * as astring from "astring";
import * as meriyah from "meriyah";
export const preprocessYouTubePlayer = (function (meriyah, astring) {
"use strict";
function matchesStructure(obj, structure) {
if (Array.isArray(structure)) {
if (!Array.isArray(obj)) return false;
return (
structure.length === obj.length &&
structure.every((value, index) => matchesStructure(obj[index], value))
);
}
if (typeof structure === "object") {
if (!obj) return !structure;
if ("or" in structure) {
return structure.or.some((node) => matchesStructure(obj, node));
}
if ("anykey" in structure && Array.isArray(structure.anykey)) {
const haystack = Array.isArray(obj) ? obj : Object.values(obj);
return structure.anykey.every((value) =>
haystack.some((element) => matchesStructure(element, value)),
);
}
for (const [key, value] of Object.entries(structure)) {
if (!matchesStructure(obj[key], value)) return false;
}
return true;
}
return structure === obj;
}
function generateArrowFunction(data) {
return meriyah.parse(data).body[0].expression;
}
const identifier = {
or: [
{
type: "ExpressionStatement",
expression: {
type: "AssignmentExpression",
operator: "=",
left: { or: [{ type: "Identifier" }, { type: "MemberExpression" }] },
right: { type: "FunctionExpression", async: false },
},
},
{ type: "FunctionDeclaration", async: false, id: { type: "Identifier" } },
{
type: "VariableDeclaration",
declarations: {
anykey: [
{
type: "VariableDeclarator",
init: { type: "FunctionExpression", async: false },
},
],
},
},
],
};
const markerCall = {
type: "ExpressionStatement",
expression: {
type: "CallExpression",
callee: {
type: "MemberExpression",
object: { type: "Identifier" },
property: {},
optional: false,
},
arguments: [
{ type: "Literal", value: "alr" },
{ type: "Literal", value: "yes" },
],
optional: false,
},
};
function extract(node) {
if (!matchesStructure(node, identifier)) return null;
const options = [];
if (node.type === "FunctionDeclaration") {
const statements = node.body?.body;
if (node.id && statements) options.push({ name: node.id, statements });
} else if (node.type === "ExpressionStatement") {
if (node.expression.type !== "AssignmentExpression") return null;
const name = node.expression.left;
const body = node.expression.right?.body?.body;
if (name && body) options.push({ name, statements: body });
} else if (node.type === "VariableDeclaration") {
for (const declaration of node.declarations) {
const name = declaration.id;
const body = declaration.init?.body?.body;
if (name && body) options.push({ name, statements: body });
}
}
for (const { name, statements } of options) {
if (matchesStructure(statements, { anykey: [markerCall] })) {
return createSolver(name);
}
}
return null;
}
function createSolver(expression) {
return generateArrowFunction(`
({sig, n}) => {
const url = (${astring.generate(expression)})("https://youtube.com/watch?v=yt-dlp-wins", "s", sig ? encodeURIComponent(sig) : undefined);
url.set("n", n);
const proto = Object.getPrototypeOf(url);
const keys = Object.keys(proto).concat(Object.getOwnPropertyNames(proto));
for (const key of keys) {
if (!["constructor", "set", "get", "clone"].includes(key)) {
url[key]();
break;
}
}
const s = url.get("s");
return {
sig: s ? decodeURIComponent(s) : null,
n: url.get("n") ?? null,
};
}
`);
}
function preprocessPlayer(data) {
const program = meriyah.parse(data);
const plainStatements = modifyPlayer(program);
const solutions = getSolutions(plainStatements);
for (const [name, options] of Object.entries(solutions)) {
plainStatements.push({
type: "ExpressionStatement",
expression: {
type: "AssignmentExpression",
operator: "=",
left: {
type: "MemberExpression",
computed: false,
object: { type: "Identifier", name: "_result" },
property: { type: "Identifier", name },
optional: false,
},
right: multiTry(options),
},
});
}
return astring.generate(program);
}
function modifyPlayer(program) {
const body = program.body;
const block = (() => {
switch (body.length) {
case 1: {
const func = body[0];
if (
func?.type === "ExpressionStatement" &&
func.expression.type === "CallExpression" &&
func.expression.callee.type === "MemberExpression" &&
func.expression.callee.object.type === "FunctionExpression"
) {
return func.expression.callee.object.body;
}
break;
}
case 2: {
const func = body[1];
if (
func?.type === "ExpressionStatement" &&
func.expression.type === "CallExpression" &&
func.expression.callee.type === "FunctionExpression"
) {
const result = func.expression.callee.body;
result.body.splice(0, 1);
return result;
}
break;
}
}
throw new Error("Unexpected YouTube player structure");
})();
block.body = block.body.filter((node) => {
if (node.type === "ExpressionStatement") {
if (node.expression.type === "AssignmentExpression") return true;
return node.expression.type === "Literal";
}
return true;
});
return block.body;
}
function getSolutions(statements) {
const found = { n: [], sig: [] };
for (const statement of statements) {
const result = extract(statement);
if (result) {
found.n.push(makeSolver(result, { type: "Identifier", name: "n" }));
found.sig.push(makeSolver(result, { type: "Identifier", name: "sig" }));
}
}
return found;
}
function makeSolver(result, identifierNode) {
return {
type: "ArrowFunctionExpression",
params: [identifierNode],
body: {
type: "MemberExpression",
object: {
type: "CallExpression",
callee: result,
arguments: [
{
type: "ObjectExpression",
properties: [
{
type: "Property",
key: identifierNode,
value: identifierNode,
kind: "init",
computed: false,
method: false,
shorthand: true,
},
],
},
],
optional: false,
},
computed: false,
property: identifierNode,
optional: false,
},
async: false,
expression: true,
generator: false,
};
}
function multiTry(generators) {
return generateArrowFunction(`
(_input) => {
const _results = new Set();
const errors = [];
for (const _generator of ${astring.generate({ type: "ArrayExpression", elements: generators })}) {
try {
_results.add(_generator(_input));
} catch (error) {
errors.push(error);
}
}
if (!_results.size) {
throw new Error(\`no solutions: \${errors.join(", ")}\`);
}
if (_results.size !== 1) {
throw new Error(\`invalid solutions: \${[..._results].map((value) => JSON.stringify(value)).join(", ")}\`);
}
return _results.values().next().value;
}
`);
}
return preprocessPlayer;
})(meriyah, astring);

View file

@ -0,0 +1,88 @@
type YoutubeAudioFormatLike = {
languageCode?: unknown;
language?: unknown;
audioTrackId?: unknown;
url?: unknown;
signatureCipher?: unknown;
audioTrack?: {
id?: unknown;
languageCode?: unknown;
language?: unknown;
};
};
export function normalizeAudioLanguageTag(value: unknown): string {
if (typeof value !== "string") return "";
return value.trim().toLowerCase().replaceAll("_", "-");
}
// Invalid candidates fall through to later metadata sources.
export function getYoutubeAudioFormatLanguage(
format: YoutubeAudioFormatLike | null | undefined,
isValid?: (tag: string) => boolean,
): string {
const accept = (value: unknown): string | undefined => {
const tag = normalizeAudioLanguageTag(value);
if (!tag) return undefined;
if (isValid && !isValid(tag)) return undefined;
return tag;
};
const direct = accept(
format?.languageCode ??
format?.language ??
format?.audioTrack?.languageCode ??
format?.audioTrack?.language,
);
if (direct) return direct;
const trackId = format?.audioTrack?.id ?? format?.audioTrackId;
if (typeof trackId === "string" && trackId) {
const idLanguage = accept(trackId.split(".")[0]);
if (idLanguage) return idLanguage;
}
try {
const cipher =
typeof format?.signatureCipher === "string"
? new URLSearchParams(format.signatureCipher)
: undefined;
const rawUrl = format?.url ?? cipher?.get("url");
if (typeof rawUrl === "string") {
const xtags = new URL(rawUrl).searchParams.get("xtags") ?? "";
const match = /(?:^|:)lang=([^:]+)/i.exec(xtags);
const fromUrl = accept(match?.[1]);
if (fromUrl) return fromUrl;
}
} catch {
// Optional URL metadata is not required for language selection.
}
return "";
}
export function selectSmallestAudioFormat<
T extends { contentLength?: unknown; averageBitrate?: unknown },
>(candidates: T[]): T | undefined {
const smallest = (key: "contentLength" | "averageBitrate") => {
let best: T | undefined;
let bestValue = Number.POSITIVE_INFINITY;
for (const candidate of candidates) {
const raw = candidate[key];
const value =
raw == null
? Number.NaN
: typeof raw === "number"
? raw
: Number(String(raw));
if (Number.isFinite(value) && value > 0 && value < bestValue) {
best = candidate;
bestValue = value;
}
}
return best;
};
return (
smallest("contentLength") ?? smallest("averageBitrate") ?? candidates[0]
);
}

View file

@ -6,7 +6,7 @@ import {
} from "@vot.js/core/types/yandex";
import type { RequestLang, ResponseLang } from "@vot.js/shared/types/data";
import { AudioDownloader } from "../audioDownloader";
import { STREAM_TIMEOUT_MS } from "../audioDownloader/strategies/webMseProxy";
import { STREAM_TIMEOUT_MS } from "../audioDownloader/strategies/webAudioBridge";
import { localizationProvider } from "../localization/localizationProvider";
import type {
DownloadedAudioData,
@ -20,7 +20,12 @@ import {
} from "../utils/abort";
import { deleteAccount, hasAccountToken } from "../utils/account";
import debug from "../utils/debug";
import { getErrorMessage, isAbortError, safeNestedGet } from "../utils/errors";
import {
getErrorMessage,
isAbortError,
makeAbortError,
safeNestedGet,
} from "../utils/errors";
import type { VideoHandler } from "../VideoHandler";
import VOTLocalizedError from "../VOTLocalizedError";
import type { VideoData } from "../videoHandler/shared";
@ -122,6 +127,89 @@ export class VOTTranslationHandler {
// In normal operation we should upload audio through the MSE proxy path.
private readonly requestedFailAudio = new Set<string>();
// Source preparation is keyed to a video and outlives a failed upload run so
// a same-video retry can replay the completed cache. Upload runs share the
// preparation signal; a different video aborts it. Handlers and finish
// callbacks check their run so stale events can't mutate the active run.
private audioRunSeq = 0;
private audioRunController: AbortController | null = null;
private readonly audioRunExternalUnlinks = new Map<AbortSignal, () => void>();
private audioRunTranslationId: string | null = null;
private audioRunVideoId: string | null = null;
private linkAudioRunAbort(
externalSignal: AbortSignal,
controller: AbortController,
): void {
if (externalSignal === NEVER_ABORTED_SIGNAL) return;
if (this.audioRunExternalUnlinks.has(externalSignal)) return;
if (externalSignal.aborted) {
controller.abort(makeAbortError());
return;
}
const onAbort = () => controller.abort(makeAbortError());
externalSignal.addEventListener("abort", onAbort, { once: true });
this.audioRunExternalUnlinks.set(externalSignal, () =>
externalSignal.removeEventListener("abort", onAbort),
);
}
private startAudioRun(
externalSignal: AbortSignal,
translationId: string,
videoId: string,
): { signal: AbortSignal; runId: number } {
this.audioRunSeq += 1;
const runId = this.audioRunSeq;
const existing = this.audioRunController;
if (
existing &&
this.audioRunVideoId === videoId &&
!existing.signal.aborted
) {
this.linkAudioRunAbort(externalSignal, existing);
this.audioRunTranslationId = translationId;
return { signal: existing.signal, runId };
}
if (existing) {
existing.abort(makeAbortError("New audio run started"));
this.cleanupAudioRun();
}
const controller = new AbortController();
this.audioRunController = controller;
this.audioRunVideoId = videoId;
this.audioRunTranslationId = translationId;
this.linkAudioRunAbort(externalSignal, controller);
return { signal: controller.signal, runId };
}
private cleanupAudioRun(): void {
for (const unlink of this.audioRunExternalUnlinks.values()) {
unlink();
}
this.audioRunExternalUnlinks.clear();
this.audioRunController = null;
this.audioRunTranslationId = null;
this.audioRunVideoId = null;
}
private finishAudioRun(runId: number): void {
if (runId !== this.audioRunSeq) return;
this.cleanupAudioRun();
}
// Resume state is intentionally kept only for a failed first audio upload.
// A successful translation clears it, so later target languages use the
// normal server-side path and do not replay YouTube audio.
private uploadResumeState: {
videoId: string;
fileId: string;
lastSuccessfulChunkId: number;
failed: boolean;
} | null = null;
constructor(videoHandler: VideoHandler) {
this.videoHandler = videoHandler;
this.audioDownloader = new AudioDownloader();
@ -146,30 +234,46 @@ export class VOTTranslationHandler {
debug.log("skip downloadedAudio");
return;
}
if (translationId !== this.audioRunTranslationId) {
debug.log("skip stale downloadedAudio", { translationId });
return;
}
const runId = this.audioRunSeq;
const signal = this.audioRunController?.signal ?? NEVER_ABORTED_SIGNAL;
const { videoId, fileId, audioData } = data;
const videoUrl = this.getCanonicalUrl(videoId);
try {
await this.retryAudioUpload(() =>
this.videoHandler.votClient.provider.requestVtransAudio(
videoUrl,
translationId,
{
audioFile: audioData,
fileId,
},
),
await this.retryAudioUpload(
(timeoutMs) =>
this.videoHandler.votClient.provider.requestVtransAudio(
videoUrl,
translationId,
{
audioFile: audioData,
fileId,
},
undefined as never,
{},
{ timeout: timeoutMs },
),
signal,
);
} catch (error) {
if (isAbortError(error) && signal.aborted) {
return;
}
debug.error("Failed to upload downloaded audio", error);
this.finishDownloadFailure(
error instanceof Error
? error
: new Error("Audio downloader failed while uploading full audio"),
runId,
);
return;
}
this.finishDownloadSuccess();
this.audioDownloader.clearCachedAudio(videoId);
this.finishDownloadSuccess(runId);
};
private readonly onDownloadedPartialAudio = async (
@ -181,45 +285,109 @@ export class VOTTranslationHandler {
debug.log("skip downloadedPartialAudio");
return;
}
if (translationId !== this.audioRunTranslationId) {
debug.log("skip stale downloadedPartialAudio", { translationId });
return;
}
const runId = this.audioRunSeq;
const signal = this.audioRunController?.signal ?? NEVER_ABORTED_SIGNAL;
const { audioData, fileId, videoId, amount, version, index } = data;
const videoUrl = this.getCanonicalUrl(videoId);
const resume = this.uploadResumeState;
if (
resume?.failed &&
resume.videoId === videoId &&
resume.fileId === fileId &&
index <= resume.lastSuccessfulChunkId
) {
debug.log("[VOT][AudioUpload] skipping already uploaded chunk", {
videoId,
fileId,
chunkId: index,
lastSuccessfulChunkId: resume.lastSuccessfulChunkId,
});
return;
}
try {
await this.retryAudioUpload(() =>
this.videoHandler.votClient.provider.requestVtransAudio(
videoUrl,
translationId,
{
audioFile: audioData,
chunkId: index,
},
{
audioPartsLength: amount ?? 0,
fileId,
version,
},
),
await this.retryAudioUpload(
(timeoutMs) =>
this.videoHandler.votClient.provider.requestVtransAudio(
videoUrl,
translationId,
{
audioFile: audioData,
chunkId: index,
},
{
audioPartsLength: amount ?? 0,
fileId,
version,
},
{},
{ timeout: timeoutMs },
),
signal,
);
this.uploadResumeState = {
videoId,
fileId,
lastSuccessfulChunkId: index,
failed: false,
};
} catch (error) {
debug.error("Failed to upload downloaded audio chunk", error);
if (isAbortError(error) && signal.aborted) {
return;
}
debug.error("[VOT][AudioUpload] chunk PUT failed after all retries", {
videoId,
fileId,
chunkId: index,
amount,
bytes: audioData.byteLength,
error,
});
this.uploadResumeState = {
videoId,
fileId,
lastSuccessfulChunkId:
this.uploadResumeState?.videoId === videoId
? this.uploadResumeState.lastSuccessfulChunkId
: index - 1,
failed: true,
};
this.finishDownloadFailure(
new Error("Audio downloader failed while uploading chunk"),
new VOTLocalizedError("VOTRetryTranslation"),
runId,
);
return;
}
if (amount !== undefined && index === amount - 1) {
this.finishDownloadSuccess();
this.uploadResumeState = null;
this.audioDownloader.clearCachedAudio(videoId);
this.finishDownloadSuccess(runId);
}
};
private readonly onDownloadAudioError = async (videoId: string) => {
private readonly onDownloadAudioError = async (
translationId: string,
videoId: string,
signInSuggested = false,
) => {
if (!this.downloading) {
debug.log("skip downloadAudioError");
return;
}
if (translationId !== this.audioRunTranslationId) {
debug.log("skip stale downloadAudioError", { translationId });
return;
}
const runId = this.audioRunSeq;
debug.log(`Failed to download audio ${videoId}`);
debug.error("[VOT][AudioDownload] failed to download audio from source", {
videoId,
});
const videoUrl = this.getCanonicalUrl(videoId);
// The fail-audio-js endpoint is a rare fallback. Keep its usage minimal and
@ -228,9 +396,14 @@ export class VOTTranslationHandler {
this.videoHandler.site.host === "youtube" &&
Boolean(this.videoHandler.data?.useAudioDownload);
if (!shouldUseFallback) {
if (signInSuggested || !shouldUseFallback) {
this.finishDownloadFailure(
new VOTLocalizedError("VOTFailedDownloadAudio"),
new VOTLocalizedError(
signInSuggested
? "VOTYouTubeSignInSuggested"
: "VOTFailedDownloadAudio",
),
runId,
);
return;
}
@ -243,24 +416,38 @@ export class VOTTranslationHandler {
await this.videoHandler.votClient.provider.requestVtransFailAudio(
videoUrl,
);
await this.videoHandler.votClient.provider.requestVtransAudio(
videoUrl,
translationId,
{
audioFile: new Uint8Array(0),
fileId: `fallback-empty-audio:video-translation:${videoId}`,
},
);
this.requestedFailAudio.add(videoUrl);
}
this.finishDownloadSuccess();
this.finishDownloadSuccess(runId);
} catch (error) {
debug.error("fail-audio-js request failed", error);
this.finishDownloadFailure(
new VOTLocalizedError("VOTFailedDownloadAudio"),
runId,
);
}
};
private finishDownloadSuccess() {
private finishDownloadSuccess(runId?: number) {
if (runId !== undefined && runId !== this.audioRunSeq) return;
this.downloading = false;
this.settleDownloadWaiters();
}
private finishDownloadFailure(error: Error) {
private finishDownloadFailure(error: Error, runId?: number) {
if (runId !== undefined && runId !== this.audioRunSeq) return;
// Settle the UI waiter with the real error promptly but keep the source
// preparation running so a same-video retry can replay the completed cache.
this.downloading = false;
this.settleDownloadWaiters(error);
}
@ -269,26 +456,48 @@ export class VOTTranslationHandler {
return `https://youtu.be/${videoId}`;
}
private static readonly AUDIO_UPLOAD_MAX_RETRIES = 2;
private static readonly AUDIO_UPLOAD_MAX_RETRIES = 5;
private static readonly AUDIO_UPLOAD_RETRY_DELAY_MS = 1500;
private static readonly AUDIO_UPLOAD_TIMEOUTS_MS = [15_000, 20_000, 30_000];
private async retryAudioUpload<T>(fn: () => Promise<T>): Promise<T> {
private async retryAudioUpload<T>(
fn: (timeoutMs: number) => Promise<T>,
signal: AbortSignal,
): Promise<T> {
const maxRetries = VOTTranslationHandler.AUDIO_UPLOAD_MAX_RETRIES;
const delayMs = VOTTranslationHandler.AUDIO_UPLOAD_RETRY_DELAY_MS;
const timeouts = VOTTranslationHandler.AUDIO_UPLOAD_TIMEOUTS_MS;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
throwIfAborted(signal);
const timeoutMs = timeouts[Math.min(attempt, timeouts.length - 1)];
try {
return await fn();
return await fn(timeoutMs);
} catch (error) {
if (signal.aborted) {
throw isAbortError(error) ? error : makeAbortError();
}
lastError = error;
const details = error as {
status?: unknown;
code?: unknown;
message?: unknown;
};
debug.error("[VOT][AudioUpload] PUT attempt failed", {
attempt: attempt + 1,
totalAttempts: maxRetries + 1,
status: details?.status,
code: details?.code,
message: details?.message ?? getErrorMessage(error),
});
if (attempt === maxRetries) {
throw error;
}
debug.log(
`[AudioUpload] retry ${attempt + 1}/${maxRetries} after ${delayMs}ms`,
`[VOT][AudioUpload] retry ${attempt + 1}/${maxRetries} after ${delayMs}ms`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
await createAbortableDelay(delayMs, signal);
}
}
throw lastError;
@ -476,11 +685,10 @@ export class VOTTranslationHandler {
translationId: res.translationId,
});
this.downloading = true;
await this.audioDownloader.runAudioDownload(
videoData.videoId,
res.translationId,
const { signal: audioSignal, runId } = this.startAudioRun(
signal,
res.translationId,
videoData.videoId,
);
debug.log("[Translation] waiting for audio download completion", {
@ -488,14 +696,29 @@ export class VOTTranslationHandler {
translationId: res.translationId,
timeoutMs: STREAM_TIMEOUT_MS,
});
await Promise.all([
this.waitForAudioDownloadCompletion(signal, STREAM_TIMEOUT_MS),
this.audioDownloader.runAudioDownload(
videoData.videoId,
res.translationId,
signal,
),
]);
const audioProducer = this.audioDownloader.runAudioDownload(
videoData.videoId,
res.translationId,
audioSignal,
requestLang,
);
try {
await Promise.all([
this.waitForAudioDownloadCompletion(audioSignal, STREAM_TIMEOUT_MS),
audioProducer,
]);
} finally {
if (runId === this.audioRunSeq) {
this.downloading = false;
}
// Release the preparation signal/link only once the producer settles.
// Until then it must stay alive so external abort stops it and a
// same-video retry can reuse it instead of downloading again.
void audioProducer.then(
() => this.finishAudioRun(runId),
() => this.finishAudioRun(runId),
);
}
// for get instant result on download end
return await this.translateVideoImpl(
@ -701,7 +924,9 @@ export class VOTTranslationHandler {
const { promise, settle } = createAbortableWaiter(signal, timeoutMs);
this.downloadSettlers.add(settle);
return promise;
return promise.finally(() => {
this.downloadSettlers.delete(settle);
});
}
private settleDownloadWaiters(error?: Error) {

View file

@ -3,6 +3,10 @@ import { getVideoData } from "@vot.js/ext/utils/videoData";
import votConfig from "@vot.js/shared/config";
import { availableLangs } from "@vot.js/shared/consts";
import type { RequestLang, ResponseLang } from "@vot.js/shared/types/data";
import {
getYoutubeAudioFormatLanguage as getYoutubeAudioFormatLanguageTag,
selectSmallestAudioFormat,
} from "../audioDownloader/utils";
import { localizationProvider } from "../localization/localizationProvider";
import debug from "../utils/debug";
import { GM_fetch } from "../utils/gm";
@ -38,6 +42,17 @@ const MAX_SHARED_LANGUAGE_STATES = 500;
const REQUEST_LANG_SET = new Set<RequestLang>(
availableLangs as readonly RequestLang[],
);
const SUPPORTED_TRANSLATION_SOURCE_LANGS = new Set<RequestLang>([
"ru",
"en",
"zh",
"ko",
"fr",
"it",
"es",
"de",
"ja",
]);
type ResolvedRequestLang = Exclude<RequestLang, "auto">;
type SharedLanguageState = {
@ -167,6 +182,45 @@ function isResolvedLanguage(
return Boolean(value && value !== "auto");
}
function resolveSupportedYoutubeAudioLanguage():
| ResolvedRequestLang
| undefined {
const response = YoutubeHelper.getPlayerResponse() as any;
const formats = [
...(Array.isArray(response?.streamingData?.adaptiveFormats)
? response.streamingData.adaptiveFormats
: []),
...(Array.isArray(response?.streamingData?.formats)
? response.streamingData.formats
: []),
].filter((format: any) => {
const mimeType = String(format?.mimeType ?? "");
return mimeType.includes("audio/") && !mimeType.includes("video/");
});
const candidates = formats
.map((format: any) => {
const language = normalizeToRequestLang(
getYoutubeAudioFormatLanguageTag(format, (value) =>
Boolean(normalizeToRequestLang(value)),
),
);
return {
language,
contentLength: format?.contentLength,
averageBitrate: format?.averageBitrate,
};
})
.filter(
(c) => c.language && SUPPORTED_TRANSLATION_SOURCE_LANGS.has(c.language),
);
const selected = selectSmallestAudioFormat(candidates);
return selected?.language && isResolvedLanguage(selected.language)
? selected.language
: undefined;
}
function buildDetectText(title: unknown, description: unknown): string {
const textTitle = typeof title === "string" ? title : "";
const textDescription =
@ -407,28 +461,62 @@ export class VOTVideoManager {
async ensureDetectedLanguageForTranslation(
videoData: RuntimeVideoData | undefined,
): Promise<void> {
if (!videoData?.videoId || videoData.detectedLanguage !== "auto") {
if (!videoData?.videoId) return;
if (videoData.detectedLanguage === "auto") {
const { detectedLanguage } = await this.resolveVideoLanguage({
videoId: videoData.videoId,
isStream: videoData.isStream,
possibleLanguage: videoData.detectedLanguage,
subtitles: videoData.subtitles,
title: videoData.title,
description: videoData.description,
allowTextLanguageDetection: true,
});
if (detectedLanguage && detectedLanguage !== "auto") {
videoData.detectedLanguage = detectedLanguage;
}
}
const detected = normalizeToRequestLang(videoData.detectedLanguage);
if (
detected &&
detected !== "auto" &&
SUPPORTED_TRANSLATION_SOURCE_LANGS.has(detected)
) {
return;
}
const { detectedLanguage } = await this.resolveVideoLanguage({
if (this.videoHandler.site.host !== "youtube") return;
const supportedVideoLanguage = resolveSupportedYoutubeAudioLanguage();
if (!supportedVideoLanguage) {
debug.log("[language] no supported YouTube audio track found", {
videoId: videoData.videoId,
detectedLanguage: videoData.detectedLanguage,
});
return;
}
const previousLanguage = videoData.detectedLanguage;
videoData.detectedLanguage = supportedVideoLanguage;
this.setDetectedLanguageCache(videoData.videoId, supportedVideoLanguage);
if (this.videoHandler.translateFromLang === "auto") {
this.videoHandler.translateFromLang = supportedVideoLanguage;
this.videoHandler.autoSourceLanguageOverrideVideoId = videoData.videoId;
this.videoHandler.setSelectMenuValues(
supportedVideoLanguage,
videoData.responseLanguage,
);
}
debug.log("[language] unsupported language switched immediately", {
videoId: videoData.videoId,
isStream: videoData.isStream,
possibleLanguage: videoData.detectedLanguage,
subtitles: videoData.subtitles,
title: videoData.title,
description: videoData.description,
allowTextLanguageDetection: true,
previousLanguage,
supportedVideoLanguage,
translateFromLang: this.videoHandler.translateFromLang,
});
if (!detectedLanguage || detectedLanguage === "auto") {
return;
}
this.videoHandler.setSelectMenuValues(
detectedLanguage,
this.videoHandler.translateToLang,
);
}
async getVideoData() {

View file

@ -1,7 +1,7 @@
{
"name": "[VOT] - Voice Over Translation",
"description": "Watch videos in other languages with voice-over translation and subtitles in any browser",
"version": "1.11.9",
"version": "1.11.17",
"author": "Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng",
"namespace": "vot",
"icon": "https://translate.yandex.ru/icons/favicon.ico",
@ -125,6 +125,7 @@
"*://learn-staging.deeplearning.ai/*",
"*://learn-dev.deeplearning.ai/*",
"*://*.netacad.com/content/i2cs/*",
"*://*.netacad.com/authoring-resources/*",
"*://*.nicovideo.jp/*",
"*://*.zdf.de/*",
"*://iframe.mediadelivery.net/*",
@ -137,6 +138,8 @@
"*://projector.datacamp.com/*",
"*://hot.noodlemagazine.com/*",
"*://fast.wistia.net/*",
"*://*.the-joi-database.com/*",
"*://*.vhx.tv/*",
"*://*/*.mp4*",
"*://*/*.webm*"
],
@ -172,6 +175,7 @@
"timeweb.cloud",
"raw.githubusercontent.com",
"vimeo.com",
"vhx.tv",
"toil.cc",
"onrender.com",
"workers.dev",
@ -179,6 +183,8 @@
"cloudflare-dns.com",
"porntn.com",
"youtube.com",
"googlevideo.com"
"googlevideo.com",
"vtrans.eu.cc",
"vot-worker.vtrans.eu.cc"
]
}

View file

@ -1,64 +1,64 @@
{
"af": "897becc2de09c3d7fa3e31da92186920",
"am": "d7c669f7f9c94041ed6c314fe64e516c",
"ar": "17d2c5ddd99477fa87a4360498317414",
"az": "64707a589a70019d756dc6eb13f43d54",
"bg": "2af5a13c20aac4da0fd801fe81e49ddb",
"bn": "25fc1482aa29ee7390d3d6dab6f3a7fa",
"bs": "4ac1ba5eae0778534ac1d68cff13b02e",
"ca": "4b7fca4d18975772b2d74cb9720537c0",
"cs": "0ccad1c0aa97eb68d2c17893cb6f3802",
"cy": "1c48eb8b3aee841412a131289c7dbab7",
"da": "402e0b9767841a02d37305dd95c97d24",
"de": "f91c4c961af353e0d37ab986916282f3",
"el": "9546164e35f1b70c48a0cdef7babcffa",
"en": "17db9eec89942f6e11498c93a213e26f",
"es": "c343eb7182a7ced664595d4a4a0ddfae",
"et": "0a7618e2d0343ddbec11add43347a7a8",
"eu": "4019ec430fe4271df7d8ab1df3c657a5",
"fa": "3092dc8d5b9172c90765e8b67a941992",
"fi": "9ab45a7f597208b2feee843b9363ba41",
"fr": "908271925c56e1488424e6a13d1cf214",
"gl": "2c3ec8fbdd8be8533631e49a25d6c26c",
"hi": "3090786b0393526776f1798c40c90766",
"hr": "6ab21d01950d4dfa56bf3c356bf37f41",
"hu": "a7f7662960fa00274de15d45879345e6",
"hy": "efcb213ff402edf9582bb36d2e3c1f28",
"id": "59f01150207aa9732c9afa06a29a4a54",
"it": "934ff987c23d5314e92170d30761d125",
"ja": "34a6e9486591e7b385094af362378876",
"jv": "fa5f2699aef3eee96b5d767516c130d5",
"kk": "a08ec4b5f507a6aeaa1e2b2a24c120e0",
"km": "0304ba06ac9f8237fd213f2307c617e3",
"kn": "10409d948dd0995d6883dbc72c6547a3",
"ko": "6dd470f7e436aaabf08a9b3baddaa56f",
"lo": "be2155d5dc1d1d37705a350f7e07c95b",
"mk": "db8c6495e94065d16104c8aa4bdfc5e9",
"ml": "5e3ae2df48e87b58ef7850e19a4f6e6d",
"mn": "973760a194dc9e8aa23e42793b02fe18",
"ms": "00aefebd55eae988fd159aa624040cc6",
"mt": "3525d33007bcaa24a757561dcd509678",
"my": "22a698b3949c261992a3fc812061f412",
"ne": "eb7ba01f8f8e2108ee272d14824651fb",
"nl": "5bff0929bac0b8febb223111e0367710",
"pa": "f190fb3bf2e251612903483300f762cb",
"pl": "f6e670f55cca1fe72a3eaac924b531b8",
"pt": "eb8a8ee4167f08e5f634cb301c94acc1",
"ro": "1ad7c9b34ab40f674b437f08f65e246e",
"ru": "d47fab0f2430be0048ab3cb646c8fed6",
"si": "6ba561b5afd8fae76dc85cefc2df9a74",
"sk": "5e760dddf07411bec4df6f2a67facef0",
"sl": "9d479a2992f4bd4051837c4aa35f3c67",
"sq": "ef690856951641692c3bad34ee0c7829",
"sr": "ed95779d4642c5cabca113e87bbb489e",
"su": "fa1e0f2c66ccc1812e56b004bcfe7358",
"sv": "83d666569084137f616af8a2a7c5b5e7",
"sw": "15e5f12cdcc308abd4571656e2aa8514",
"tr": "7899cc6fc77dbfba94017a22ad8e4c69",
"uk": "07c12c77a42891f113e6663f3ba58aef",
"ur": "e664b7f25e348aec2337c996e1d8fdd9",
"uz": "94bea528a1cf804cb421ba814cc1511e",
"vi": "88095ca13291bc027f7e38129db06743",
"zh": "4d1848e8f49e916332e8f9f276db96e3",
"zu": "31286dfd52dfe2fb63e5d8b252855bde"
"af": "3f4932c501ad7257118327d68701d63d",
"am": "0ce721cc3b44a261fb8d7dd767c5515e",
"ar": "fec6fc8b50d2062ac8f8c02ece2b0412",
"az": "19cc8aaa5a9fe7961bd13893fe02c5bd",
"bg": "4f3663d7c85b405737fcb18e6454329b",
"bn": "c0fe40e54232e218999ee187e10f27c4",
"bs": "ac4d3e063646ee0187196edecc0b5061",
"ca": "8197e4b0eb90306881cf03cc0a18e8d0",
"cs": "9a92de36c844c974d82eb0a3ac7102f9",
"cy": "b9eafda6c59c5843cf1d0e4fdd3d256f",
"da": "0bf66d2d8cebbd68e2210735fd48bd26",
"de": "693dc0fa6d95f207aadf0738effe5fe6",
"el": "84ec30bcadeea146a359e66be35ad52f",
"en": "6d917f205e7c8d889f0d2f0fef2af937",
"es": "9007d775384a52767cfa4d5973d7949b",
"et": "d25e5a779d1612f93d89108e4ef8f66d",
"eu": "a459a61223a447c7c3c78588d7a5a4bc",
"fa": "7c7049bedf029ce72cd96cfde9c756ea",
"fi": "a3a9e692aa72841aa99eaef596dda82f",
"fr": "861f58daf4a700c0bd742cb7252c51d3",
"gl": "f267d84f8f8ee8d8dc1b3cf8c404dc36",
"hi": "f0525fe6cf12e225ef41a79d1a13083a",
"hr": "1f7c120ad300f053cadd155d422db24c",
"hu": "64b48a10b8dab9ebdef4fbac076cbc48",
"hy": "674c30be96564aedde5d43db7d9ec4b6",
"id": "eb7fdf726603687f566f7d613cc7a2d1",
"it": "9dbe9a5eac19ccae6513dde0d0d74c5b",
"ja": "8e932f63edc5f46cecd078c9bcc2098a",
"jv": "8300e4d198edc16fce168b723b3ddb54",
"kk": "acfc70b31c58739406efe7a62df28c82",
"km": "09326af4ac3dfa11a46b1d4507a17a6e",
"kn": "48ab83909038a0dbe099168ed1b1d2d7",
"ko": "dbc16cbcda5783a844910b610c0ee1ae",
"lo": "a4b19bba4add7447907a74a344793d8e",
"mk": "21064fb60ab868f6ba5406e406778b30",
"ml": "2096d8219af41a2568615877397218a8",
"mn": "2ff84c1d00a19de11b40ad6655e33f5a",
"ms": "d767744e085e3fef1d7efd229cab4d32",
"mt": "521dd6048e6fad34115c2c2443a4df5a",
"my": "46168915fa789d4d6afbec1b4afe1ac6",
"ne": "133c955a1c8c5d15a43dadd7005d0a6c",
"nl": "678951b695d05c29e14b4fbde413fe8a",
"pa": "89810ac99de59e5960f43ad0db8030b5",
"pl": "3ebfb43f129e00fe13b3c111430a5e92",
"pt": "dbe37e141a20b0be1ca25b52ce315ae5",
"ro": "7272cccff857a455bc2c1ea9b7f3b0ce",
"ru": "237c68699e9e887f7e18e6d267695e8e",
"si": "39d3ce4e562cab0fbafd5649e7e0d248",
"sk": "ff831055ffd0222fe429a410e346fc0a",
"sl": "69bfcfc529929d0ecc4a3586ed082332",
"sq": "2807b3255ece069fdfd496b866d4d784",
"sr": "806a3069c3ef7ac711222d7e69947487",
"su": "96999d1fce12788260314fb41a36979c",
"sv": "73ea7949339b76c1e1e44a1a33ead281",
"sw": "66458a80ef57266b1e3195239e18008a",
"tr": "493b1eb37fba94fe305914af52783d52",
"uk": "7391b71ca2c16ec82f8b26eeb42784ee",
"ur": "a0f0299963960b7dd7429ea42afc6ef1",
"uz": "3bd60048e050ed881e8c6ea37580680f",
"vi": "1e5c7fcfc08c4862a94c4c7894d6d284",
"zh": "3ce4571b988316619e1d39d63febc78a",
"zu": "7e71e0d9a7ba879dcb84d0a4d8d15c4d"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Die vertaling sal ongeveer {0} minute neem",
"requestTranslationFailed": "Kon nie video-vertaling aanvra nie",
"audioNotReceived": "Oudio skakel nie ontvang nie",
"VOTYouTubeSignInSuggested": "Meld by YouTube aan en probeer die vertaling weer",
"audioFormatNotSupported": "Die oudioformaat word nie ondersteun nie",
"VOTAutoTranslate": "Vertaal op open",
"VOTDontTranslateYourLang": "Moenie uit my taal vertaal nie",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Lewendige stemme",
"VOTLiveVoicesSubtitle": "Maksimum ooreenkoms. Asof almal russies ken",
"VOTYandexTokenExpired": "Sessie verstryk het. Meld weer aan",
"VOTVoiceSelection": "Kies dubbing"
"VOTVoiceSelection": "Kies dubbing",
"VOTRetryTranslation": "Probeer vertaling weer"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ትርጉሙ በግምት ይወስዳል {0} ደቂቃዎች",
"requestTranslationFailed": "የቪዲዮ ትርጉም መጠየቅ አልተሳካም",
"audioNotReceived": "የድምፅ አገናኝ አልተቀበለም",
"VOTYouTubeSignInSuggested": "ወደ YouTube ይግቡ እና ትርጉሙን እንደገና ይሞክሩ",
"audioFormatNotSupported": "የድምጽ ቅርጸት አይደገፍም",
"VOTAutoTranslate": "ክፈት ላይ መተርጎም",
"VOTDontTranslateYourLang": "ከቋንቋዬ አትበል",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "የቀጥታ ድምጾች",
"VOTLiveVoicesSubtitle": "ከፍተኛው ተመሳሳይነት. ሁሉም ሰው ሩሲያኛ ያውቃል ከሆነ እንደ",
"VOTYandexTokenExpired": "ስብሰባው አብቅቷል። እንደገና ይግቡ",
"VOTVoiceSelection": "ዱባይን ይምረጡ"
"VOTVoiceSelection": "ዱባይን ይምረጡ",
"VOTRetryTranslation": "ትርጉሙን እንደገና ሞክር"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ستستغرق الترجمة تقريبا {0} دقيقة",
"requestTranslationFailed": "فشل طلب ترجمة الفيديو",
"audioNotReceived": "لم يتم استلام رابط الصوت",
"VOTYouTubeSignInSuggested": "سجّل الدخول إلى YouTube وحاول الترجمة مرة أخرى",
"audioFormatNotSupported": "تنسيق الصوت غير مدعوم",
"VOTAutoTranslate": "الترجمة عند الفتح",
"VOTDontTranslateYourLang": "لا تترجم من لغتي",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "أصوات حية",
"VOTLiveVoicesSubtitle": "التشابه الأقصى. كما لو أن الجميع يعرف الروسية",
"VOTYandexTokenExpired": "انتهت الجلسة. تسجيل الدخول مرة أخرى",
"VOTVoiceSelection": "اختر الدبلجة"
"VOTVoiceSelection": "اختر الدبلجة",
"VOTRetryTranslation": "إعادة محاولة الترجمة"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Tərcümə təxminən {0} dəqiqə çəkəcək",
"requestTranslationFailed": "Videonun tərcüməsini tələb etmək mümkün olmadı",
"audioNotReceived": "Səs bağlantısı alınmadı",
"VOTYouTubeSignInSuggested": "YouTube-a daxil olun və tərcüməni yenidən sınayın",
"audioFormatNotSupported": "Audio format dəstəklənmir",
"VOTAutoTranslate": "Açıq tərcümə",
"VOTDontTranslateYourLang": "Dilimdən tərcümə etmə",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Canlı səslər",
"VOTLiveVoicesSubtitle": "Maksimum oxşarlıq. Sanki hamı rus dilini bilir",
"VOTYandexTokenExpired": "Sessiya bitdi. Yenidən daxil olun",
"VOTVoiceSelection": "Dublyaj seçin"
"VOTVoiceSelection": "Dublyaj seçin",
"VOTRetryTranslation": "Tərcüməni yenidən sınayın"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Преводът ще отнеме около {0} минути",
"requestTranslationFailed": "Неуспешно искане за превод на видео",
"audioNotReceived": "Аудио връзката не е получена",
"VOTYouTubeSignInSuggested": "Влезте в YouTube и опитайте превода отново",
"audioFormatNotSupported": "Аудио форматът не се поддържа",
"VOTAutoTranslate": "Превод на отворен",
"VOTDontTranslateYourLang": "Не превеждайте от моя език",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Живи гласове",
"VOTLiveVoicesSubtitle": "Максимално сходство. Сякаш всички знаят руски.",
"VOTYandexTokenExpired": "Сесията изтече. Влезте отново",
"VOTVoiceSelection": "Изберете дублаж"
"VOTVoiceSelection": "Изберете дублаж",
"VOTRetryTranslation": "Повторен опит за превод"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "একটি অনুবাদ প্রায় {0} মিনিট সময় নেবে",
"requestTranslationFailed": "ভিডিও অনুবাদের অনুরোধ ব্যর্থ হয়েছে",
"audioNotReceived": "অডিও লিঙ্ক পাওয়া যায়নি",
"VOTYouTubeSignInSuggested": "YouTube-এ সাইন ইন করুন এবং আবার অনুবাদ করার চেষ্টা করুন",
"audioFormatNotSupported": "অডিও ফরম্যাটটি সমর্থিত নয়",
"VOTAutoTranslate": "খোলার সময় অনুবাদ করুন",
"VOTDontTranslateYourLang": "নিজের ভাষা অনুবাদ না করুন",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "লাইভ ভয়েসেস",
"VOTLiveVoicesSubtitle": "সর্বাধিক সাদৃশ্য. যেন সবাই রাশিয়ান জানে",
"VOTYandexTokenExpired": "অধিবেশন শেষ আবার লগ ইন করুন",
"VOTVoiceSelection": "ডাবিং চয়ন করুন"
"VOTVoiceSelection": "ডাবিং চয়ন করুন",
"VOTRetryTranslation": "অনুবাদ আবার চেষ্টা করুন"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Prevod će trajati otprilike {0} minuta",
"requestTranslationFailed": "Neuspjela prijava.",
"audioNotReceived": "Audio link nije primljen",
"VOTYouTubeSignInSuggested": "Prijavite se na YouTube i pokušajte ponovo prevesti",
"audioFormatNotSupported": "Audio format nije podržan",
"VOTAutoTranslate": "Prevedi na open",
"VOTDontTranslateYourLang": "Ne prevodite sa mog jezika",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Živi glasovi",
"VOTLiveVoicesSubtitle": "Maksimalna sličnost. Kao da svi znaju ruski",
"VOTYandexTokenExpired": "Sesija je istekla. Prijavite se ponovo",
"VOTVoiceSelection": "Odaberite sinhronizaciju"
"VOTVoiceSelection": "Odaberite sinhronizaciju",
"VOTRetryTranslation": "Pokušaj ponovo prevesti"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "La traducció trigarà aproximadament {0} minuts",
"requestTranslationFailed": "Ha fallat en demanar la traducció de vídeo",
"audioNotReceived": "No s 'ha rebut l' enllaç d ' àudio",
"VOTYouTubeSignInSuggested": "Inicia la sessió a YouTube i torna a provar la traducció",
"audioFormatNotSupported": "El format d'àudio no és compatible",
"VOTAutoTranslate": "Tradueix en obert",
"VOTDontTranslateYourLang": "No tradueixis de la meva llengua",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Veus en directe",
"VOTLiveVoicesSubtitle": "Màxima similitud. Com si tothom conegués el rus",
"VOTYandexTokenExpired": "Sessió expirada. Inicia sessió de nou",
"VOTVoiceSelection": "Tria doblatge"
"VOTVoiceSelection": "Tria doblatge",
"VOTRetryTranslation": "Torna a provar la traducció"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Překlad zabere přibližně {0} minuty",
"requestTranslationFailed": "Nepodařilo se požádat o překlad videa",
"audioNotReceived": "Nepřišel odkaz na zvuk",
"VOTYouTubeSignInSuggested": "Přihlaste se na YouTube a zkuste překlad znovu",
"audioFormatNotSupported": "Formát zvuku není podporován",
"VOTAutoTranslate": "Překládat automaticky při otevření",
"VOTDontTranslateYourLang": "Nepřekládat z vašeho jazyka",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Živé hlasy",
"VOTLiveVoicesSubtitle": "Maximální podobnost. Jako by každý věděl rusky",
"VOTYandexTokenExpired": "Relace vypršela. Přihlásit se znovu",
"VOTVoiceSelection": "Vyberte si Dabing"
"VOTVoiceSelection": "Vyberte si Dabing",
"VOTRetryTranslation": "Zkusit překlad znovu"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Bydd y cyfieithiad yn cymryd oddeutu {0} munud",
"requestTranslationFailed": "Methu gofyn am gyfieithiad fideo",
"audioNotReceived": "Ni dderbyniwyd y cyswllt sain",
"VOTYouTubeSignInSuggested": "Mewngofnodwch i YouTube a rhowch gynnig arall ar y cyfieithiad",
"audioFormatNotSupported": "Ni chynhelir y fformat sain",
"VOTAutoTranslate": "Cyfieithu ar agor",
"VOTDontTranslateYourLang": "Peidiwch â chyfieithu o fy iaith",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Lleisiau byw",
"VOTLiveVoicesSubtitle": "Tebygrwydd mwyaf. Fel pe bai pawb yn gwybod rwsieg",
"VOTYandexTokenExpired": "Daeth y sesiwn i ben. Mewngofnodi eto",
"VOTVoiceSelection": "Dewiswch dybio"
"VOTVoiceSelection": "Dewiswch dybio",
"VOTRetryTranslation": "Rhoi cynnig arall ar y cyfieithiad"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Oversættelsen tager cirka {0} minutter",
"requestTranslationFailed": "Kunne ikke anmode om videooversættelse",
"audioNotReceived": "Audio link ikke modtaget",
"VOTYouTubeSignInSuggested": "Log ind på YouTube, og prøv oversættelsen igen",
"audioFormatNotSupported": "Lydformatet understøttes ikke",
"VOTAutoTranslate": "Oversæt på åben",
"VOTDontTranslateYourLang": "Oversæt ikke fra mit sprog",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Levende stemmer",
"VOTLiveVoicesSubtitle": "Maksimal lighed. Som om alle kender russisk",
"VOTYandexTokenExpired": "Sessionen er udløbet. Log ind igen",
"VOTVoiceSelection": "Vælg dubbing"
"VOTVoiceSelection": "Vælg dubbing",
"VOTRetryTranslation": "Prøv oversættelsen igen"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Die Übersetzung dauert ungefähr {0} Minuten",
"requestTranslationFailed": "Videoübersetzung konnte nicht angefordert werden",
"audioNotReceived": "Audiolink nicht empfangen",
"VOTYouTubeSignInSuggested": "Melde dich bei YouTube an und versuche die Übersetzung erneut",
"audioFormatNotSupported": "Das Audioformat wird nicht unterstützt",
"VOTAutoTranslate": "Beim Öffnen übersetzen",
"VOTDontTranslateYourLang": "Nicht aus Ihrer Sprache übersetzen",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Live Stimmen",
"VOTLiveVoicesSubtitle": "Maximale Ähnlichkeit. Als ob jeder Russisch kann",
"VOTYandexTokenExpired": "Sitzung abgelaufen. Erneut anmelden",
"VOTVoiceSelection": "Wählen Sie Synchronisation"
"VOTVoiceSelection": "Wählen Sie Synchronisation",
"VOTRetryTranslation": "Übersetzung erneut versuchen"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Η μετάφραση θα διαρκέσει περίπου {0} λεπτά",
"requestTranslationFailed": "Αποτυχία αίτησης μετάφρασης βίντεο",
"audioNotReceived": "Δεν έχει ληφθεί σύνδεσμος ήχου",
"VOTYouTubeSignInSuggested": "Συνδεθείτε στο YouTube και δοκιμάστε ξανά τη μετάφραση",
"audioFormatNotSupported": "Η μορφή ήχου δεν υποστηρίζεται",
"VOTAutoTranslate": "Μετάφραση σε ανοιχτό",
"VOTDontTranslateYourLang": "Μην μεταφράζετε από τη γλώσσα μου",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Ζωντανές φωνές",
"VOTLiveVoicesSubtitle": "Μέγιστη ομοιότητα. Σαν να γνωρίζουν όλοι ρωσική",
"VOTYandexTokenExpired": "Η συνεδρία έληξε. Συνδεθείτε ξανά",
"VOTVoiceSelection": "Επιλέξτε μεταγλώττιση"
"VOTVoiceSelection": "Επιλέξτε μεταγλώττιση",
"VOTRetryTranslation": "Επανάληψη μετάφρασης"
}

View file

@ -17,6 +17,7 @@
"translationTakeApproximatelyMinute": "The translation will take approximately {0} minutes",
"requestTranslationFailed": "Failed to request video translation",
"audioNotReceived": "Audio link not received",
"VOTYouTubeSignInSuggested": "Sign in to YouTube and try translation again",
"VOTFailedDownloadAudio": "Failed to download audio",
"audioFormatNotSupported": "The audio format is not supported",
"VOTAutoTranslate": "Translate on open",
@ -252,5 +253,6 @@
"VOTLoginViaToken": "Login via token",
"smartDucking": "Adaptive volume",
"VOTYandexTokenExpired": "Session expired. Log in again",
"VOTVoiceSelection": "Choose dubbing"
"VOTVoiceSelection": "Choose dubbing",
"VOTRetryTranslation": "Retry translation"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "La traducción tardará aproximadamente {0} minutos",
"requestTranslationFailed": "Error al solicitar la traducción de vídeo",
"audioNotReceived": "Enlace de audio no recibido",
"VOTYouTubeSignInSuggested": "Inicia sesión en YouTube e intenta traducir de nuevo",
"audioFormatNotSupported": "El formato de audio no es compatible",
"VOTAutoTranslate": "Traducir en abierto",
"VOTDontTranslateYourLang": "No traduzcas de mi idioma",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Voces en vivo",
"VOTLiveVoicesSubtitle": "Máxima similitud. Como si todo el mundo supiera ruso",
"VOTYandexTokenExpired": "La sesión expiró. Inicie sesión de nuevo",
"VOTVoiceSelection": "Elegir doblaje"
"VOTVoiceSelection": "Elegir doblaje",
"VOTRetryTranslation": "Reintentar traducción"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Tõlge võtab umbes {0} minutit",
"requestTranslationFailed": "Videotõlke taotlemine nurjus",
"audioNotReceived": "Audio link ei saanud",
"VOTYouTubeSignInSuggested": "Logige YouTube’i sisse ja proovige tõlget uuesti",
"audioFormatNotSupported": "Helivormingut ei toetata",
"VOTAutoTranslate": "Tõlgi avatud",
"VOTDontTranslateYourLang": "Ära tõlgi minu keelest",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Elavad hääled",
"VOTLiveVoicesSubtitle": "Maksimaalne sarnasus. Nagu kõik teaksid vene keelt",
"VOTYandexTokenExpired": "Seanss on aegunud. Logi uuesti sisse",
"VOTVoiceSelection": "Valige dubleerimine"
"VOTVoiceSelection": "Valige dubleerimine",
"VOTRetryTranslation": "Proovi tõlget uuesti"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Itzulpenak gutxi gorabehera {0} minutu hartuko ditu",
"requestTranslationFailed": "Huts egin du bideoaren itzulpena eskatzean",
"audioNotReceived": "Audio esteka ez da jaso",
"VOTYouTubeSignInSuggested": "Hasi saioa YouTuben eta saiatu berriro itzultzen",
"audioFormatNotSupported": "Audio formatua ez dago onartuta",
"VOTAutoTranslate": "Itzuli irekita",
"VOTDontTranslateYourLang": "Ez itzuli nire hizkuntzatik",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Bizi ahotsak",
"VOTLiveVoicesSubtitle": "Antzekotasun handiena. Denek errusiera dakite",
"VOTYandexTokenExpired": "Saioa amaitu da. Saioa hasi berriro",
"VOTVoiceSelection": "Aukeratu bikoizketa"
"VOTVoiceSelection": "Aukeratu bikoizketa",
"VOTRetryTranslation": "Saiatu itzulpena berriro"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ترجمه تقریباً {0} دقیقه طول می کشد",
"requestTranslationFailed": "امکان درخواست ترجمه ویدیو وجود ندارد",
"audioNotReceived": "پیدا نشدن پیوند صوتی",
"VOTYouTubeSignInSuggested": "وارد YouTube شوید و ترجمه را دوباره امتحان کنید",
"audioFormatNotSupported": "قالب صوتی پشتیبانی نمی شود",
"VOTAutoTranslate": "ترجمه خودکار",
"VOTDontTranslateYourLang": "از زبان مادری خود ترجمه نکنید",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "صداهای زنده",
"VOTLiveVoicesSubtitle": "حداکثر شباهت. گویی همه روسی می دانند",
"VOTYandexTokenExpired": "جلسه منقضی شد. دوباره وارد شوید",
"VOTVoiceSelection": "دوبله را انتخاب کنید"
"VOTVoiceSelection": "دوبله را انتخاب کنید",
"VOTRetryTranslation": "تلاش مجدد برای ترجمه"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Käännös vie noin {0} minuuttia",
"requestTranslationFailed": "Videon käännöksen pyyntö epäonnistui",
"audioNotReceived": "Äänilinkkiä ei vastaanotettu",
"VOTYouTubeSignInSuggested": "Kirjaudu YouTubeen ja yritä käännöstä uudelleen",
"audioFormatNotSupported": "Äänimuotoa ei tueta",
"VOTAutoTranslate": "Käännä auki",
"VOTDontTranslateYourLang": "Älä käännä kielestäni",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Live voices",
"VOTLiveVoicesSubtitle": "Suurin samankaltaisuus. Aivan kuin kaikki osaisivat Venäjää",
"VOTYandexTokenExpired": "Istunto päättyi. Kirjaudu sisään uudelleen",
"VOTVoiceSelection": "Valitse dubbaus"
"VOTVoiceSelection": "Valitse dubbaus",
"VOTRetryTranslation": "Yritä käännöstä uudelleen"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "La traduction prendra environ {0} minutes",
"requestTranslationFailed": "Impossible de demander la traduction de la vidéo",
"audioNotReceived": "Lien audio non reçu",
"VOTYouTubeSignInSuggested": "Connectez-vous à YouTube et réessayez la traduction",
"audioFormatNotSupported": "Format audio non pris en charge",
"VOTAutoTranslate": "Traduire à l'ouverture",
"VOTDontTranslateYourLang": "Ne pas traduire à partir de votre langue",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Voix en direct",
"VOTLiveVoicesSubtitle": "Similitude maximale. Comme si tout le monde connaissait le russe",
"VOTYandexTokenExpired": "La session a expiré. Connectez-vous à nouveau",
"VOTVoiceSelection": "Choisissez le doublage"
"VOTVoiceSelection": "Choisissez le doublage",
"VOTRetryTranslation": "Réessayer la traduction"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "A tradución levará aproximadamente {0} minutos",
"requestTranslationFailed": "Non foi posíbel solicitar a tradución de vídeo",
"audioNotReceived": "Enlace de Audio non recibido",
"VOTYouTubeSignInSuggested": "Inicia sesión en YouTube e tenta traducir de novo",
"audioFormatNotSupported": "O formato de audio non é compatible",
"VOTAutoTranslate": "Traducir en aberto",
"VOTDontTranslateYourLang": "Non traducir do meu idioma",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Voces en directo",
"VOTLiveVoicesSubtitle": "Máxima similitude. Como se todos coñecesen o galego",
"VOTYandexTokenExpired": "A sesión rematou. Entra de novo",
"VOTVoiceSelection": "Escoller o doblaxe"
"VOTVoiceSelection": "Escoller o doblaxe",
"VOTRetryTranslation": "Tentar de novo a tradución"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "{0} मिनट के बारे में अनुवाद लगेगा",
"requestTranslationFailed": "वीडियो अनुवाद का अनुरोध विफल रहा",
"audioNotReceived": "ऑडियो का लिंक प्राप्त नहीं हुआ",
"VOTYouTubeSignInSuggested": "YouTube में साइन इन करें और फिर से अनुवाद करने की कोशिश करें",
"audioFormatNotSupported": "ऑडियो फ़ॉर्मेट समर्थित नहीं है",
"VOTAutoTranslate": "खोलने पर अनुवाद करें",
"VOTDontTranslateYourLang": "अपनी भाषा से अनुवाद न करें",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "लाइव आवाज",
"VOTLiveVoicesSubtitle": "अधिकतम समानता। जैसे कि हर कोई रूसी जानता है",
"VOTYandexTokenExpired": "सत्र समाप्त हो गया । फिर से लॉग इन करें",
"VOTVoiceSelection": "डबिंग चुनें"
"VOTVoiceSelection": "डबिंग चुनें",
"VOTRetryTranslation": "अनुवाद का फिर से प्रयास करें"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Prijevod će trajati približno {0} minuta",
"requestTranslationFailed": "Nije moguće zatražiti prijevod videozapisa",
"audioNotReceived": "Audio veza nije primljena",
"VOTYouTubeSignInSuggested": "Prijavite se na YouTube i pokušajte ponovno prevesti",
"audioFormatNotSupported": "Audio format nije podržan",
"VOTAutoTranslate": "Prevedi na otvoreno",
"VOTDontTranslateYourLang": "Nemojte prevoditi s mog jezika",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Živi glasovi",
"VOTLiveVoicesSubtitle": "Maksimalna sličnost. Kao da svi znaju ruski",
"VOTYandexTokenExpired": "Sesija je istekla. Ponovno se prijavite",
"VOTVoiceSelection": "Odaberite presnimavanje"
"VOTVoiceSelection": "Odaberite presnimavanje",
"VOTRetryTranslation": "Pokušaj ponovno prevesti"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "A fordítás körülbelül {0} percet vesz igénybe",
"requestTranslationFailed": "Nem sikerült a videó fordítását kérni",
"audioNotReceived": "Az Audio link nem érkezett meg",
"VOTYouTubeSignInSuggested": "Jelentkezz be a YouTube-ra, és próbáld meg újra a fordítást",
"audioFormatNotSupported": "Az audio formátum nem támogatott",
"VOTAutoTranslate": "Fordítás nyitva",
"VOTDontTranslateYourLang": "Ne fordítson az én nyelvemről",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Élő hangok",
"VOTLiveVoicesSubtitle": "Maximális hasonlóság. Mint ha mindenki tudja, orosz",
"VOTYandexTokenExpired": "A munkamenet lejárt. Jelentkezzen be újra",
"VOTVoiceSelection": "Válassza ki a szinkronizálást"
"VOTVoiceSelection": "Válassza ki a szinkronizálást",
"VOTRetryTranslation": "Fordítás újrapróbálása"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Թարգմանությունը կտեւի մոտավորապես {0} րոպե",
"requestTranslationFailed": "Չհաջողվեց պահանջել տեսանյութի թարգմանություն",
"audioNotReceived": "Աուդիո հաղորդակցություն չի ստացվել",
"VOTYouTubeSignInSuggested": "Մուտք գործեք YouTube և կրկին փորձեք թարգմանել",
"audioFormatNotSupported": "Աուդիո Ձևաչափը չի ապահովվում",
"VOTAutoTranslate": "Թարգմանել բաց",
"VOTDontTranslateYourLang": "Մի թարգմանիր իմ լեզվից",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Կենդանի ձայներ",
"VOTLiveVoicesSubtitle": "Առավելագույն նմանություն: Կարծես բոլորը գիտեն ռուսերեն",
"VOTYandexTokenExpired": "Նիստը լրացավ: Կրկին մուտք գործեք համակարգ",
"VOTVoiceSelection": "Ընտրեք կրկնօրինակ"
"VOTVoiceSelection": "Ընտրեք կրկնօրինակ",
"VOTRetryTranslation": "Կրկին փորձել թարգմանությունը"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Terjemahan akan memakan waktu sekitar {0} menit",
"requestTranslationFailed": "Tidak dapat meminta terjemahan video",
"audioNotReceived": "Tidak ada tautan audio yang diterima",
"VOTYouTubeSignInSuggested": "Login ke YouTube dan coba terjemahkan lagi",
"audioFormatNotSupported": "Format audio tidak didukung",
"VOTAutoTranslate": "Terjemahkan saat membuka",
"VOTDontTranslateYourLang": "Jangan terjemahkan dari bahasa asli Anda",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Suara langsung",
"VOTLiveVoicesSubtitle": "Kesamaan maksimum. Seolah-olah semua orang tahu bahasa Rusia",
"VOTYandexTokenExpired": "Sesi berakhir. Masuk lagi",
"VOTVoiceSelection": "Pilih sulih suara"
"VOTVoiceSelection": "Pilih sulih suara",
"VOTRetryTranslation": "Coba terjemahkan lagi"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "La traduzione richiederà circa {0} minuti",
"requestTranslationFailed": "Richiesta di traduzione video non riuscita",
"audioNotReceived": "Collegamento audio non ricevuto",
"VOTYouTubeSignInSuggested": "Accedi a YouTube e prova di nuovo la traduzione",
"audioFormatNotSupported": "Il formato audio non è supportato",
"VOTAutoTranslate": "Traduci su apri",
"VOTDontTranslateYourLang": "Non tradurre dalla mia lingua",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Voci dal vivo",
"VOTLiveVoicesSubtitle": "Massima somiglianza. Come se tutti conoscessero il russo",
"VOTYandexTokenExpired": "Sessione scaduta. Accedi di nuovo",
"VOTVoiceSelection": "Scegli il doppiaggio"
"VOTVoiceSelection": "Scegli il doppiaggio",
"VOTRetryTranslation": "Riprova la traduzione"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "翻訳には約{0}分かかります",
"requestTranslationFailed": "動画の翻訳リクエストに失敗しました",
"audioNotReceived": "音声リンクが受信されませんでした",
"VOTYouTubeSignInSuggested": "YouTube にログインして、もう一度翻訳をお試しください",
"audioFormatNotSupported": "音声形式がサポートされていません",
"VOTAutoTranslate": "開いたときに翻訳する",
"VOTDontTranslateYourLang": "自分の言語からは翻訳しない",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "ライブボイス",
"VOTLiveVoicesSubtitle": "最大の類似性。 誰もがロシア語を知っているかのように",
"VOTYandexTokenExpired": "セッションの有効期限が切れました。 もう一度ログインします",
"VOTVoiceSelection": "ダビングを選ぶ"
"VOTVoiceSelection": "ダビングを選ぶ",
"VOTRetryTranslation": "翻訳を再試行"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Terjemahan bakal njupuk kira-kira {0} menit",
"requestTranslationFailed": "Gagal kanggo njaluk terjemahan video",
"audioNotReceived": "Link Audio ora ditampa",
"VOTYouTubeSignInSuggested": "Mlebu menyang YouTube lan coba terjemahake maneh",
"audioFormatNotSupported": "Format audio ora didhukung",
"VOTAutoTranslate": "Nerjemahake ing mbukak",
"VOTDontTranslateYourLang": "Aja nerjemahké saka basanku",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Swara urip",
"VOTLiveVoicesSubtitle": "Kamiripan maksimal. Kaya-kaya kabèh wong ngerti basa rusia",
"VOTYandexTokenExpired": "Sesi wis kadaluwarsa. Mlebu maneh",
"VOTVoiceSelection": "Milih dubbing"
"VOTVoiceSelection": "Milih dubbing",
"VOTRetryTranslation": "Coba terjemahake maneh"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Аударма қазірғі түрде {0} минутқа жағынан аударылатын болады",
"requestTranslationFailed": "Бейнені аудару талап етуден кейін сәтсіз аяқталды",
"audioNotReceived": "Аудиоға сілтеме алынған жоқ",
"VOTYouTubeSignInSuggested": "YouTube жүйесіне кіріп, аударманы қайтадан қолданып көріңіз",
"audioFormatNotSupported": "Аудио пішімі қолдау көрсетмейді",
"VOTAutoTranslate": "Ашылада переводдау",
"VOTDontTranslateYourLang": "Өз тіліңнен таратпау",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Тірі дауыстар",
"VOTLiveVoicesSubtitle": "Максималды ұқсастық. Барлығы орыс тілін білетін сияқты",
"VOTYandexTokenExpired": "Сессияның мерзімі аяқталды. Қайта кіріңіз",
"VOTVoiceSelection": "Дубляжды таңдаңыз"
"VOTVoiceSelection": "Дубляжды таңдаңыз",
"VOTRetryTranslation": "Аударманы қайталап көру"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ការបកប្រែនេះនឹងចំណាយពេលប្រមាណ{0}នាទី",
"requestTranslationFailed": "បរាជ័យក្នុងការស្នើសុំបកប្រែវីដេអូ",
"audioNotReceived": "មិនបានទទួលតំណអូឌីយ៉ូ",
"VOTYouTubeSignInSuggested": "ចូលគណនី YouTube ហើយសាកល្បងបកប្រែម្តងទៀត",
"audioFormatNotSupported": "ទ្រង់ទ្រាយអូឌីយ៉ូមិនត្រូវបានគាំទ្រទេ",
"VOTAutoTranslate": "បកប្រែបើក",
"VOTDontTranslateYourLang": "កុំបកប្រែពីភាសារបស់ខ្ញុំ",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "សំឡេងផ្ទាល់",
"VOTLiveVoicesSubtitle": "ភាពស្រដៀងគ្នាអតិបរមា។ ដូចជាប្រសិនបើអ្នករាល់គ្នាដឹងរុស្ស៊ី",
"VOTYandexTokenExpired": "សម័យដែលបានផុតកំណត់. ចូលម្តងទៀត",
"VOTVoiceSelection": "ជ្រើស dubbing"
"VOTVoiceSelection": "ជ្រើស dubbing",
"VOTRetryTranslation": "សាកល្បងបកប្រែម្តងទៀត"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ಅನುವಾದವು ಸರಿಸುಮಾರು {0} ನಿಮಿಷಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತದೆ",
"requestTranslationFailed": "ವೀಡಿಯೊ ಅನುವಾದವನ್ನು ಕೋರಲು ವಿಫಲಗೊಂಡಿದೆ",
"audioNotReceived": "ಆಡಿಯೋ ಲಿಂಕ್ ಬಂದಿಲ್ಲ",
"VOTYouTubeSignInSuggested": "YouTube ಗೆ ಸೈನ್ ಇನ್ ಮಾಡಿ ಮತ್ತು ಅನುವಾದವನ್ನು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ",
"audioFormatNotSupported": "ಆಡಿಯೋ ಸ್ವರೂಪ ಬೆಂಬಲವಿಲ್ಲ",
"VOTAutoTranslate": "ತೆರೆದ ಮೇಲೆ ಭಾಷಾಂತರಿಸಿ",
"VOTDontTranslateYourLang": "'ನನ್ನ ಭಾಷೆಯಿಂದ ಭಾಷಾಂತರಿಸಬೇಡಿ'",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "ಲೈವ್ ಧ್ವನಿಗಳು",
"VOTLiveVoicesSubtitle": "ಗರಿಷ್ಠ ಹೋಲಿಕೆ. ಎಲ್ಲರಿಗೂ ರಷ್ಯನ್ ತಿಳಿದಿರುವಂತೆ",
"VOTYandexTokenExpired": "ಅಧಿವೇಶನ ಮುಗಿದಿದೆ. ಮತ್ತೆ ಲಾಗ್ ಇನ್ ಮಾಡಿ",
"VOTVoiceSelection": "ಡಬ್ಬಿಂಗ್ ಆಯ್ಕೆ"
"VOTVoiceSelection": "ಡಬ್ಬಿಂಗ್ ಆಯ್ಕೆ",
"VOTRetryTranslation": "ಅನುವಾದವನ್ನು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "번역에 약 {0}분 걸립니다",
"requestTranslationFailed": "비디오 번역 요청 실패",
"audioNotReceived": "오디오 링크를 받지 못했습니다",
"VOTYouTubeSignInSuggested": "YouTube에 로그인한 후 번역을 다시 시도하세요",
"audioFormatNotSupported": "오디오 형식이 지원되지 않습니다",
"VOTAutoTranslate": "열 때 번역하기",
"VOTDontTranslateYourLang": "내 언어에서 번역하지 않기",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "라이브 보이스",
"VOTLiveVoicesSubtitle": "최대 유사성. 모두가 러시아어를 알고있는 것처럼",
"VOTYandexTokenExpired": "세션이 만료되었습니다. 다시 로그인",
"VOTVoiceSelection": "더빙 선택"
"VOTVoiceSelection": "더빙 선택",
"VOTRetryTranslation": "번역 다시 시도"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ການແປພາສາຈະໃຊ້ເວລາປະມານ{0}ນາທີ",
"requestTranslationFailed": "ລົ້ມເຫລວໃນການຮ້ອງຂໍການແປພາສາວິດີໂອ",
"audioNotReceived": "ການເຊື່ອມຕໍ່ສຽງບໍ່ໄດ້ຮັບ",
"VOTYouTubeSignInSuggested": "ເຂົ້າສູ່ລະບົບ YouTube ແລ້ວລອງແປອີກຄັ້ງ",
"audioFormatNotSupported": "ຮູບແບບສຽງບໍ່ໄດ້ຮັບການສະໜັບສະໜູນ",
"VOTAutoTranslate": "ແປພາສາເປີດ",
"VOTDontTranslateYourLang": "ຢ່າແປຈາກພາສາຂອງຂ້ອຍ",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "ສຽງສົດ",
"VOTLiveVoicesSubtitle": "ຄວາມຄ້າຍຄືກັນສູງສຸດ. ຄືກັບວ່າທຸກຄົນຮູ້ພາສາລັດເຊຍ",
"VOTYandexTokenExpired": "ກອງປະຊຸມຫມົດອາຍຸ. ເຂົ້າສູ່ລະບົບອີກຄັ້ງ",
"VOTVoiceSelection": "ເລືອກ dubbing"
"VOTVoiceSelection": "ເລືອກ dubbing",
"VOTRetryTranslation": "ລອງແປອີກຄັ້ງ"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Преводот ќе трае приближно {0} минути",
"requestTranslationFailed": "Не успеа да побара видео превод",
"audioNotReceived": "Аудио врската не е примена",
"VOTYouTubeSignInSuggested": "Најавете се на YouTube и обидете се повторно со преводот",
"audioFormatNotSupported": "Аудио форматот не е поддржан",
"VOTAutoTranslate": "Преведи на отворено",
"VOTDontTranslateYourLang": "Не преведувајте од мојот јазик",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Гласови во живо",
"VOTLiveVoicesSubtitle": "Максимална сличност. Како секој да знае руски",
"VOTYandexTokenExpired": "Сесијата истече. Пријавете се повторно",
"VOTVoiceSelection": "Изберете синхронизација"
"VOTVoiceSelection": "Изберете синхронизација",
"VOTRetryTranslation": "Обиди се повторно со преводот"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "പരിഭാഷ ഏകദേശം {0} മിനിറ്റ് എടുക്കും",
"requestTranslationFailed": "വീഡിയോ പരിഭാഷ ആവശ്യപ്പെടുന്നതില് പരാജയം",
"audioNotReceived": "ഓഡിയോ ലിങ്ക് ലഭ്യമല്ല",
"VOTYouTubeSignInSuggested": "YouTube-ൽ സൈൻ ഇൻ ചെയ്‌ത് വിവർത്തനം വീണ്ടും ശ്രമിക്കുക",
"audioFormatNotSupported": "ഓഡിയോ ഫോർമാറ്റ് പിന്തുണയ്ക്കുന്നില്ല",
"VOTAutoTranslate": "ഓപ്പണിൽ വിവർത്തനം ചെയ്യുക",
"VOTDontTranslateYourLang": "എന്റെ ഭാഷയിൽ നിന്ന് വിവർത്തനം ചെയ്യരുത്",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "ലൈവ് ശബ്ദങ്ങൾ",
"VOTLiveVoicesSubtitle": "പരമാവധി സമാനത. എല്ലാവരും റഷ്യൻ അറിയുന്നു പോലെ",
"VOTYandexTokenExpired": "സെഷൻ അവസാനിച്ചു. വീണ്ടും ലോഗിൻ ചെയ്യുക",
"VOTVoiceSelection": "ഡബ്ബിംഗ് തിരഞ്ഞെടുക്കുക"
"VOTVoiceSelection": "ഡബ്ബിംഗ് തിരഞ്ഞെടുക്കുക",
"VOTRetryTranslation": "വിവർത്തനം വീണ്ടും ശ്രമിക്കുക"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Орчуулга нь ойролцоогоор {0} минут болно",
"requestTranslationFailed": "Видео орчуулга хийх хүсэлт гаргаагүй",
"audioNotReceived": "Аудио холбоос хүлээн аваагүй",
"VOTYouTubeSignInSuggested": "YouTube-д нэвтэрч, орчуулгыг дахин оролдоно уу",
"audioFormatNotSupported": "Аудио форматыг дэмждэггүй",
"VOTAutoTranslate": "Нээлттэй дээр орчуулах",
"VOTDontTranslateYourLang": "Миний хэлнээс битгий орчуулаарай",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Амьд дуу хоолой",
"VOTLiveVoicesSubtitle": "Хамгийн их төстэй. Хэрэв хүн бүр мэддэг орос",
"VOTYandexTokenExpired": "Хуралдаан дууссан. Дахин нэвтрэх",
"VOTVoiceSelection": "Дубляж сонгох"
"VOTVoiceSelection": "Дубляж сонгох",
"VOTRetryTranslation": "Орчуулгыг дахин оролдох"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Terjemahan akan mengambil masa selama kira-kira {0} minit",
"requestTranslationFailed": "Gagal meminta terjemahan video",
"audioNotReceived": "Link audio tidak diterima",
"VOTYouTubeSignInSuggested": "Log masuk ke YouTube dan cuba terjemahan sekali lagi",
"audioFormatNotSupported": "Format audio tidak disokong",
"VOTAutoTranslate": "Terjemahkan semasa pembukaan",
"VOTDontTranslateYourLang": "Jangan terjemahkan dari bahasa asal anda",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Hidup suara-suara",
"VOTLiveVoicesSubtitle": "Persamaan maksimum. Seolah-olah semua orang tahu Rusia",
"VOTYandexTokenExpired": "Sesi tamat. Log masuk lagi",
"VOTVoiceSelection": "Pilih Alih Suara"
"VOTVoiceSelection": "Pilih Alih Suara",
"VOTRetryTranslation": "Cuba terjemahan sekali lagi"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "It-traduzzjoni tieħu madwar {0} minuti",
"requestTranslationFailed": "Naqas milli jitlob traduzzjoni bil-vidjo",
"audioNotReceived": "Link awdjo mhux riċevut",
"VOTYouTubeSignInSuggested": "Idħol f’YouTube u erġa’ pprova t-traduzzjoni",
"audioFormatNotSupported": "Il-format tal-awdjo mhuwiex appoġġjat",
"VOTAutoTranslate": "Ittraduċi fuq miftuħ",
"VOTDontTranslateYourLang": "Tittraduċix mil lingwa tiegħi",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Vuċijiet ħajjin",
"VOTLiveVoicesSubtitle": "Xebh massimu. Bħallikieku kulħadd jaf ir russu",
"VOTYandexTokenExpired": "Sessjoni skada. Idħol mill-ġdid",
"VOTVoiceSelection": "Agħżel id-doppjaġġ"
"VOTVoiceSelection": "Agħżel id-doppjaġġ",
"VOTRetryTranslation": "Erġa’ pprova t-traduzzjoni"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ဘာသာပြန်ဆိုမှုသည်{0}မိနစ်ခန့်ကြာလိမ့်မည်။",
"requestTranslationFailed": "ဗီဒီယိုဘာသာပြန်မှုကိုတောင်းဆိုရန်ပျက်ကွက်",
"audioNotReceived": "အသံလင့်ခ်ကိုမရရှိသေးပါ။",
"VOTYouTubeSignInSuggested": "YouTube သို့ အကောင့်ဝင်ပြီး ဘာသာပြန်ခြင်းကို ထပ်မံကြိုးစားပါ",
"audioFormatNotSupported": "အသံပုံစံကိုမထောက်ပံ့ပါ။",
"VOTAutoTranslate": "ပွင့်လင်းအပေါ်ဘာသာပြန်ဆို",
"VOTDontTranslateYourLang": "ငါ့ဘာသာစကားကနေဘာသာပြန်မနေနဲ့။",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "တိုက်ရိုက်အသံများ",
"VOTLiveVoicesSubtitle": "အများဆုံးတူညီမှုပါ။ လူတိုင်းကရုရှားလိုသိနေသလိုပေါ့။",
"VOTYandexTokenExpired": "အစည်းအဝေးကပြီးသွားပြီ။ နောက်တစ်ကြိမ်ပြန်လည်ဝင်ရောက်",
"VOTVoiceSelection": "Dubbing ကိုရွေးပါ။"
"VOTVoiceSelection": "Dubbing ကိုရွေးပါ။",
"VOTRetryTranslation": "ဘာသာပြန်ခြင်းကို ထပ်စမ်းကြည့်ပါ"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "अनुवाद लगभग {0} मिनेट लाग्नेछ",
"requestTranslationFailed": "भिडियो अनुवाद अनुरोध गर्न असफल भयो",
"audioNotReceived": "अडियो लिङ्क प्राप्त भएन",
"VOTYouTubeSignInSuggested": "YouTube मा साइन इन गर्नुहोस् र अनुवाद फेरि प्रयास गर्नुहोस्",
"audioFormatNotSupported": "अडियो ढाँचा समर्थित छैन",
"VOTAutoTranslate": "खुला मा अनुवाद गर्नुहोस्",
"VOTDontTranslateYourLang": "मेरो भाषाबाट अनुवाद नगर्नुहोस्",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "प्रत्यक्ष आवाजहरू",
"VOTLiveVoicesSubtitle": "अधिकतम समानता मानौं सबैलाई रुसी भाषा थाहा छ",
"VOTYandexTokenExpired": "सत्र समाप्त भयो । फेरि लग इन गर्नुहोस्",
"VOTVoiceSelection": "डबिंग रोज्नुहोस्"
"VOTVoiceSelection": "डबिंग रोज्नुहोस्",
"VOTRetryTranslation": "अनुवाद पुन: प्रयास गर्नुहोस्"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "De vertaling duurt ongeveer {0} minuten",
"requestTranslationFailed": "Verzoek om videovertaling is mislukt",
"audioNotReceived": "Audioverbinding niet ontvangen",
"VOTYouTubeSignInSuggested": "Log in bij YouTube en probeer de vertaling opnieuw",
"audioFormatNotSupported": "Het audioformaat wordt niet ondersteund",
"VOTAutoTranslate": "Vertalen op open",
"VOTDontTranslateYourLang": "Niet vertalen vanuit mijn taal",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Live voices",
"VOTLiveVoicesSubtitle": "Maximale gelijkenis. Alsof iedereen Russisch kent",
"VOTYandexTokenExpired": "Sessie verlopen. Opnieuw inloggen",
"VOTVoiceSelection": "Kies dubbing"
"VOTVoiceSelection": "Kies dubbing",
"VOTRetryTranslation": "Vertaling opnieuw proberen"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ਅਨੁਵਾਦ ਲਗਭਗ {0} ਮਿੰਟ ਲਵੇਗਾ",
"requestTranslationFailed": "ਵੀਡੀਓ ਅਨੁਵਾਦ ਲਈ ਬੇਨਤੀ ਕਰਨ ਲਈ ਫੇਲ੍ਹ ਹੈ",
"audioNotReceived": "ਆਡੀਓ ਲਿੰਕ ਨਹੀਂ ਮਿਲਿਆ",
"VOTYouTubeSignInSuggested": "YouTube ਵਿੱਚ ਸਾਈਨ ਇਨ ਕਰੋ ਅਤੇ ਅਨੁਵਾਦ ਦੁਬਾਰਾ ਅਜ਼ਮਾਓ",
"audioFormatNotSupported": "ਆਡੀਓ ਫਾਰਮੈਟ ਸਹਾਇਕ ਨਹੀਂ ਹੈ",
"VOTAutoTranslate": "ਓਪਨ ਉੱਤੇ ਅਨੁਵਾਦ ਕਰੋ",
"VOTDontTranslateYourLang": "ਮੇਰੀ ਭਾਸ਼ਾ ਤੋਂ ਅਨੁਵਾਦ ਨਾ ਕਰੋ",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "ਲਾਈਵ ਆਵਾਜ਼ਾਂ",
"VOTLiveVoicesSubtitle": "ਵੱਧ ਤੋਂ ਵੱਧ ਸਮਾਨਤਾ ਜਿਵੇਂ ਕਿ ਹਰ ਕੋਈ ਰੂਸੀ ਜਾਣਦਾ ਹੈ",
"VOTYandexTokenExpired": "ਸੈਸ਼ਨ ਖਤਮ ਹੋ ਗਿਆ ਹੈ. ਮੁੜ ਲਾਗਇਨ ਕਰੋ",
"VOTVoiceSelection": "ਡਬਿੰਗ ਚੁਣੋ"
"VOTVoiceSelection": "ਡਬਿੰਗ ਚੁਣੋ",
"VOTRetryTranslation": "ਅਨੁਵਾਦ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Tłumaczenie zajmie około {0} minut",
"requestTranslationFailed": "Nie udało się poprosić o tłumaczenie wideo",
"audioNotReceived": "Łącze Audio nie zostało odebrane",
"VOTYouTubeSignInSuggested": "Zaloguj się do YouTube i spróbuj ponownie przetłumaczyć",
"audioFormatNotSupported": "Format audio nie jest obsługiwany",
"VOTAutoTranslate": "Translate on open",
"VOTDontTranslateYourLang": "Nie tłumacz z mojego języka",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Głosy na żywo",
"VOTLiveVoicesSubtitle": "Maksymalne podobieństwo. Jakby wszyscy znali rosyjski",
"VOTYandexTokenExpired": "Sesja wygasła. Zaloguj się ponownie",
"VOTVoiceSelection": "Wybierz dubbing"
"VOTVoiceSelection": "Wybierz dubbing",
"VOTRetryTranslation": "Ponów próbę tłumaczenia"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "A tradução levará aproximadamente {0} minuto",
"requestTranslationFailed": "Falha ao solicitar a tradução do vídeo",
"audioNotReceived": "Link de áudio não recebido",
"VOTYouTubeSignInSuggested": "Inicie sessão no YouTube e tente traduzir novamente",
"audioFormatNotSupported": "Formato de áudio não suportado",
"VOTAutoTranslate": "Traduzir automaticamente ao abrir",
"VOTDontTranslateYourLang": "Não traduzir do seu idioma nativo",
@ -250,5 +251,6 @@
"VOTLiveVoicesTitle": "Vozes ao vivo",
"VOTLiveVoicesSubtitle": "O máximo de semelhança. Como se todos soubessem russo",
"VOTYandexTokenExpired": "A sessão expirou. Iniciar sessão novamente",
"VOTVoiceSelection": "Escolha a dublagem"
"VOTVoiceSelection": "Escolha a dublagem",
"VOTRetryTranslation": "Tentar tradução novamente"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Traducerea va dura aproximativ {0} minute",
"requestTranslationFailed": "Nu s-a solicitat traducerea video",
"audioNotReceived": "Link-ul Audio nu a fost primit",
"VOTYouTubeSignInSuggested": "Conectați-vă la YouTube și încercați din nou traducerea",
"audioFormatNotSupported": "Formatul audio nu este acceptat",
"VOTAutoTranslate": "Traduceți pe deschis",
"VOTDontTranslateYourLang": "Nu Traduceți din limba mea",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Voci Live",
"VOTLiveVoicesSubtitle": "Similitudine maximă. Ca și cum toată lumea știe limba rusă",
"VOTYandexTokenExpired": "Sesiunea a expirat. Conectați-vă din nou",
"VOTVoiceSelection": "Alegeți dublarea"
"VOTVoiceSelection": "Alegeți dublarea",
"VOTRetryTranslation": "Reîncearcă traducerea"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Перевод займёт примерно {0} минуты",
"requestTranslationFailed": "Не удалось запросить перевод видео",
"audioNotReceived": "Не получена ссылка на аудио",
"VOTYouTubeSignInSuggested": "Войдите в YouTube и повторите перевод",
"audioFormatNotSupported": "Формат аудио не поддерживается",
"VOTAutoTranslate": "Переводить при открытии",
"VOTAutoSubtitles": "Субтитры при открытии",
@ -251,5 +252,6 @@
"subtitlesSmartLayout": "Умное расположение субтитров",
"smartDucking": "Адаптивная громкость",
"VOTYandexTokenExpired": "Сессия истекла. Войдите снова",
"VOTVoiceSelection": "Выбрать озвучку"
"VOTVoiceSelection": "Выбрать озвучку",
"VOTRetryTranslation": "Повторить перевод"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "පරිවර්තනය සඳහා ආසන්න වශයෙන් {0} විනාඩි ගතවනු ඇත",
"requestTranslationFailed": "වීඩියෝ පරිවර්තනය ඉල්ලා සිටීමට අසමත්",
"audioNotReceived": "ශ්රව්ය සබැඳිය ලැබී නැත",
"VOTYouTubeSignInSuggested": "YouTube වෙත පුරනය වී පරිවර්තනය නැවත උත්සාහ කරන්න",
"audioFormatNotSupported": "ශ් රව් ය ආකෘතිය සහාය නොදක්වයි",
"VOTAutoTranslate": "විවෘත මත පරිවර්තනය කරන්න",
"VOTDontTranslateYourLang": "මගේ භාෂාවෙන් පරිවර්තනය කරන්න එපා",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "සජීවී හඬවල්",
"VOTLiveVoicesSubtitle": "උපරිම සමානතාවය. හැමෝම රුසියානු දන්නවා වගේ",
"VOTYandexTokenExpired": "සැසිය කල් ඉකුත් විය. නැවත පිවිසෙන්න",
"VOTVoiceSelection": "ඩබ් කිරීම තෝරන්න"
"VOTVoiceSelection": "ඩබ් කිරීම තෝරන්න",
"VOTRetryTranslation": "පරිවර්තනය නැවත උත්සාහ කරන්න"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Preklad bude trvať približne {0} minút",
"requestTranslationFailed": "Nepodarilo sa požiadať o preklad videa",
"audioNotReceived": "Audio odkaz nebol prijatý",
"VOTYouTubeSignInSuggested": "Prihláste sa do YouTube a skúste preklad znova",
"audioFormatNotSupported": "Formát zvuku nie je podporovaný",
"VOTAutoTranslate": "Preložiť na open",
"VOTDontTranslateYourLang": "Neprekladajte z môjho jazyka",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Živé hlasy",
"VOTLiveVoicesSubtitle": "Maximálna podobnosť. Ako keby každý vedel rusky",
"VOTYandexTokenExpired": "Platnosť relácie vypršala. Prihlásiť sa",
"VOTVoiceSelection": "Vyberte dabing"
"VOTVoiceSelection": "Vyberte dabing",
"VOTRetryTranslation": "Skúsiť preklad znova"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Prevod bo trajal približno {0} minut",
"requestTranslationFailed": "Ni bilo mogoče zahtevati prevoda videa",
"audioNotReceived": "Zvočna povezava ni prejeta",
"VOTYouTubeSignInSuggested": "Prijavite se v YouTube in znova poskusite s prevodom",
"audioFormatNotSupported": "Zvočni format ni podprt",
"VOTAutoTranslate": "Prevedi na odprto",
"VOTDontTranslateYourLang": "Ne prevajaj iz mojega jezika",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Glasovi v živo",
"VOTLiveVoicesSubtitle": "Največja podobnost. Kot da vsi vedo rusko",
"VOTYandexTokenExpired": "Seja je potekla. Ponovno se prijavite",
"VOTVoiceSelection": "Izberite sinhronizacijo"
"VOTVoiceSelection": "Izberite sinhronizacijo",
"VOTRetryTranslation": "Poskusi prevod znova"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Përkthimi do të zgjasë afërsisht {0} minuta",
"requestTranslationFailed": "Nuk arriti të kërkojë përkthim video",
"audioNotReceived": "Lidhja Audio nuk është marrë",
"VOTYouTubeSignInSuggested": "Hyni në YouTube dhe provoni përsëri përkthimin",
"audioFormatNotSupported": "Formati audio nuk mbështetet",
"VOTAutoTranslate": "Përktheni në open",
"VOTDontTranslateYourLang": "Mos përktheni nga gjuha ime",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Zërat e gjallë",
"VOTLiveVoicesSubtitle": "Ngjashmëria maksimale. Sikur të gjithë e dinë rusisht",
"VOTYandexTokenExpired": "Seanca skadoi. Hyni përsëri",
"VOTVoiceSelection": "Zgjidhni dublimin"
"VOTVoiceSelection": "Zgjidhni dublimin",
"VOTRetryTranslation": "Provo përsëri përkthimin"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Превод ће трајати приближно {0} минута",
"requestTranslationFailed": "Захтев за превод видео записа није успео",
"audioNotReceived": "Аудио комуникација није примљена",
"VOTYouTubeSignInSuggested": "Пријавите се на YouTube и покушајте поново са преводом",
"audioFormatNotSupported": "Аудио формат није подржан",
"VOTAutoTranslate": "Преведи на отворено",
"VOTDontTranslateYourLang": "Не преводите са мог језика",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Живи гласови",
"VOTLiveVoicesSubtitle": "Максимална сличност. Као да сви знају руски",
"VOTYandexTokenExpired": "Сесија је истекла. Поново се пријавите",
"VOTVoiceSelection": "Изаберите дуб"
"VOTVoiceSelection": "Изаберите дуб",
"VOTRetryTranslation": "Покушај превод поново"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Tarjamahan bakal nyandak kira-kira {0} menit",
"requestTranslationFailed": "Gagal menta tarjamahan video",
"audioNotReceived": "Link Audio teu narima",
"VOTYouTubeSignInSuggested": "Asup ka YouTube teras cobian tarjamahan deui",
"audioFormatNotSupported": "Format audio teu dirojong",
"VOTAutoTranslate": "Tarjamahkeun dina buka",
"VOTDontTranslateYourLang": "Ulah narjamahkeun tina basa kuring",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Sora nu hirup",
"VOTLiveVoicesSubtitle": "Kamiripan maksimum. Seolah olah semuanya tahu rusia",
"VOTYandexTokenExpired": "Sesi kadaluarsa. Asup deui",
"VOTVoiceSelection": "Pilih dubbing"
"VOTVoiceSelection": "Pilih dubbing",
"VOTRetryTranslation": "Coba tarjamahkeun deui"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Översättningen tar ungefär {0} minuter",
"requestTranslationFailed": "Misslyckades med att begära videoöversättning",
"audioNotReceived": "Ljudlänk inte mottagen",
"VOTYouTubeSignInSuggested": "Logga in på YouTube och försök översätta igen",
"audioFormatNotSupported": "Ljudformatet stöds inte",
"VOTAutoTranslate": "Översätt på öppna",
"VOTDontTranslateYourLang": "Översätt inte från mitt språk",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Levande röster",
"VOTLiveVoicesSubtitle": "Maximal likhet. Som om alla vet ryska",
"VOTYandexTokenExpired": "Sessionen har gått ut. Logga in igen",
"VOTVoiceSelection": "Välj dubbning"
"VOTVoiceSelection": "Välj dubbning",
"VOTRetryTranslation": "Försök översätta igen"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Tafsiri itachukua takriban {0} dakika",
"requestTranslationFailed": "Imeshindwa kuomba tafsiri ya video",
"audioNotReceived": "Kiungo cha sauti hakijapokelewa",
"VOTYouTubeSignInSuggested": "Ingia kwenye YouTube na ujaribu kutafsiri tena",
"audioFormatNotSupported": "Umbizo la sauti halitumiki",
"VOTAutoTranslate": "Tafsiri juu ya wazi",
"VOTDontTranslateYourLang": "Usitafsiri kutoka kwa lugha yangu",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Sauti za moja kwa moja",
"VOTLiveVoicesSubtitle": "Kufanana kwa kiwango cha juu. Kama kila mtu anajua kirusi",
"VOTYandexTokenExpired": "Kipindi kiliisha. Ingia tena",
"VOTVoiceSelection": "Chagua dubbing"
"VOTVoiceSelection": "Chagua dubbing",
"VOTRetryTranslation": "Jaribu kutafsiri tena"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Çeviri yaklaşık {0} dakika sürecek",
"requestTranslationFailed": "Video çevirisi isteği başarısız oldu",
"audioNotReceived": "Ses bağlantısı alınamadı",
"VOTYouTubeSignInSuggested": "YouTube’da oturum açın ve çeviriyi tekrar deneyin",
"audioFormatNotSupported": "Ses formatı desteklenmiyor",
"VOTAutoTranslate": "Açılışta çevir",
"VOTDontTranslateYourLang": "Ana dilinizden çevirme",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Canlı sesler",
"VOTLiveVoicesSubtitle": "Maksimum benzerlik. Sanki herkes Rusça biliyormuş gibi",
"VOTYandexTokenExpired": "Oturumun süresi doldu. Tekrar giriş yap",
"VOTVoiceSelection": "Dublajı seçin"
"VOTVoiceSelection": "Dublajı seçin",
"VOTRetryTranslation": "Çeviriyi yeniden dene"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Переклад займе приблизно {0} хвилини",
"requestTranslationFailed": "Не вдалося запросити Переклад відео",
"audioNotReceived": "Аудіосвязь не отримана",
"VOTYouTubeSignInSuggested": "Увійдіть у YouTube і повторіть переклад",
"audioFormatNotSupported": "Аудіоформат не підтримується",
"VOTAutoTranslate": "Перевести на відкритий",
"VOTDontTranslateYourLang": "Не перекладайте з моєї мови",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Живі голоси",
"VOTLiveVoicesSubtitle": "Максимальна схожість. Наче всі знають російську",
"VOTYandexTokenExpired": "Сеанс закінчився. Увійдіть ще раз",
"VOTVoiceSelection": "Виберіть дубляж"
"VOTVoiceSelection": "Виберіть дубляж",
"VOTRetryTranslation": "Повторити переклад"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "ترجمہ تقریباً {0} منٹ لیگا",
"requestTranslationFailed": "ویڈیو ترجمہ کرنے میں ناکامی",
"audioNotReceived": "آڈیو کا لنک حاصل نہیں ہوا",
"VOTYouTubeSignInSuggested": "YouTube میں سائن ان کریں اور دوبارہ ترجمہ کرنے کی کوشش کریں",
"audioFormatNotSupported": "آڈیو فارمیٹ کی حمایت نہیں کی جاتی",
"VOTAutoTranslate": "کھولنے پر ترجمہ کریں",
"VOTDontTranslateYourLang": "اپنی زبان سے ترجمہ نہ کریں",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "لائیو آوازیں",
"VOTLiveVoicesSubtitle": "زیادہ سے زیادہ مماثلت. گویا ہر کوئی روسی جانتا ہے ۔ ",
"VOTYandexTokenExpired": "سیشن ختم ہو گیا. دوبارہ لاگ ان کریں",
"VOTVoiceSelection": "ڈبنگ کا انتخاب کریں"
"VOTVoiceSelection": "ڈبنگ کا انتخاب کریں",
"VOTRetryTranslation": "ترجمہ دوبارہ آزمائیں"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Tarjima taxminan {0} daqiqa davom etadi",
"requestTranslationFailed": "Video tarjimasini talab qilib bo'lmadi",
"audioNotReceived": "Audio havola qabul qilinmadi",
"VOTYouTubeSignInSuggested": "YouTube hisobiga kiring va tarjimani qayta urinib ko‘ring",
"audioFormatNotSupported": "Audio format mavjud emas",
"VOTAutoTranslate": "Ochiq tarjima qiling",
"VOTDontTranslateYourLang": "Mening tilimdan tarjima qilmang",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Jonli ovozlar",
"VOTLiveVoicesSubtitle": "Maksimal o'xshashlik. Go'yo hamma rus tilini biladi",
"VOTYandexTokenExpired": "Sessiya muddati tugadi. Qayta kiring",
"VOTVoiceSelection": "Dublyajni tanlang"
"VOTVoiceSelection": "Dublyajni tanlang",
"VOTRetryTranslation": "Tarjimani qayta urinib ko‘ring"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Dịch sẽ mất khoảng {0} phút",
"requestTranslationFailed": "Không thể yêu cầu dịch video",
"audioNotReceived": "Không nhận được liên kết âm thanh",
"VOTYouTubeSignInSuggested": "Đăng nhập vào YouTube và thử dịch lại",
"audioFormatNotSupported": "Định dạng âm thanh không được hỗ trợ",
"VOTAutoTranslate": "Tự động dịch khi mở",
"VOTDontTranslateYourLang": "Không dịch từ ngôn ngữ gốc của bạn",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Giọng nói trực tiếp",
"VOTLiveVoicesSubtitle": "Sự tương đồng tối đa. Như thể mọi người đều biết tiếng nga",
"VOTYandexTokenExpired": "Phiên hết hạn. Đăng nhập lại",
"VOTVoiceSelection": "Chọn lồng tiếng"
"VOTVoiceSelection": "Chọn lồng tiếng",
"VOTRetryTranslation": "Thử dịch lại"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "翻译大约需要{0}分钟",
"requestTranslationFailed": "请求视频翻译失败",
"audioNotReceived": "未收到音频链接",
"VOTYouTubeSignInSuggested": "登录 YouTube 后重试翻译",
"audioFormatNotSupported": "不支持音频格式",
"VOTAutoTranslate": "打开翻译",
"VOTDontTranslateYourLang": "不要从我的语言翻译",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "现场声音",
"VOTLiveVoicesSubtitle": "最大相似度。 好像每个人都知道俄语",
"VOTYandexTokenExpired": "会话过期。 再次登入",
"VOTVoiceSelection": "选择配音"
"VOTVoiceSelection": "选择配音",
"VOTRetryTranslation": "重试翻译"
}

View file

@ -16,6 +16,7 @@
"translationTakeApproximatelyMinute": "Ukuhumusha kuzothatha cishe imizuzu {0}",
"requestTranslationFailed": "Yehlulekile ukucela ukuhunyushwa kwevidiyo",
"audioNotReceived": "Isixhumanisi somsindo asitholakalanga",
"VOTYouTubeSignInSuggested": "Ngena ngemvume ku-YouTube bese uzama ukuhumusha futhi",
"audioFormatNotSupported": "Ifomethi yomsindo ayisekelwa",
"VOTAutoTranslate": "Ukuhumusha ku-open",
"VOTDontTranslateYourLang": "Ungahumushi kusuka olimini lwami",
@ -251,5 +252,6 @@
"VOTLiveVoicesTitle": "Amazwi aphilayo",
"VOTLiveVoicesSubtitle": "Ukufana okuphezulu. Njengokungathi wonke umuntu uyazi isirashiya",
"VOTYandexTokenExpired": "Isikhathi sesiphelile. Ngena futhi",
"VOTVoiceSelection": "Choose dubbing"
"VOTVoiceSelection": "Choose dubbing",
"VOTRetryTranslation": "Zama ukuhumusha futhi"
}

View file

@ -3,6 +3,7 @@ import type { AudioDownloader } from "../audioDownloader";
export type GetAudioFromAPIOptions = {
videoId: string;
signal: AbortSignal;
sourceLanguage?: string;
};
export type AudioDownloadRequestOptions = {
@ -10,6 +11,7 @@ export type AudioDownloadRequestOptions = {
translationId: string;
videoId: string;
signal: AbortSignal;
sourceLanguage?: string;
};
export type DownloadedAudioData = {

View file

@ -86,7 +86,9 @@ export type Phrase =
| "translationTakeApproximatelyMinute"
| "requestTranslationFailed"
| "audioNotReceived"
| "VOTYouTubeSignInSuggested"
| "VOTFailedDownloadAudio"
| "VOTRetryTranslation"
| "audioFormatNotSupported"
| "VOTAutoTranslate"
| "VOTAutoSubtitles"
@ -336,7 +338,9 @@ export type Phrases = {
translationTakeApproximatelyMinute: string;
requestTranslationFailed: string;
audioNotReceived: string;
VOTYouTubeSignInSuggested: string;
VOTFailedDownloadAudio: string;
VOTRetryTranslation: string;
audioFormatNotSupported: string;
VOTAutoTranslate: string;
VOTAutoSubtitles: string;

View file

@ -129,7 +129,7 @@ export function createShadowMount({
styles: hostStyles,
});
const shadowRoot = host.attachShadow({
const shadowRoot = Element.prototype.attachShadow.call(host, {
mode: "open",
delegatesFocus,
});

Some files were not shown because too many files have changed in this diff Show more