mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-10-19 07:43:33 +00:00
Compare commits
52 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af99cad99e | ||
|
|
3379770a2b | ||
|
|
8659658b1e | ||
|
|
0d51587b62 | ||
|
|
9e4797fb70 | ||
|
|
9d65097a46 | ||
|
|
d27ecd316a | ||
|
|
350c529013 | ||
|
|
9cb7091150 | ||
|
|
7fe59bc833 | ||
|
|
9246bc03f7 | ||
|
|
16348de4ac | ||
|
|
e74ca75e8b | ||
|
|
c9a502500a | ||
|
|
54f014a5ed | ||
|
|
988b568c90 | ||
|
|
c50a5556f4 | ||
|
|
afcb445354 | ||
|
|
35e02fee95 | ||
|
|
a584030429 | ||
|
|
e37faf503a | ||
|
|
0aa3c4cc60 | ||
|
|
b5c5e0748e | ||
|
|
2e86c63f73 | ||
|
|
345fefee30 | ||
|
|
e5657f4877 | ||
|
|
105a6371a0 | ||
|
|
0938ce341d | ||
|
|
82c6290ec4 | ||
|
|
d8125fc662 | ||
|
|
77fe9f2337 | ||
|
|
533a55f49a | ||
|
|
013f5b40a8 | ||
|
|
2ba2576cba | ||
|
|
8b4e6cc0c1 | ||
|
|
34bdfa5b4c | ||
|
|
c3e08dc707 | ||
|
|
810e9cd48e | ||
|
|
eec61ea9ae | ||
|
|
606a9feb41 | ||
|
|
7a823f1143 | ||
|
|
68952e4135 | ||
|
|
fa9789b4a9 | ||
|
|
c39bac2a30 | ||
|
|
7efa6d3bcd | ||
|
|
d41c3d7da0 | ||
|
|
72bac1c0a5 | ||
|
|
6c6b1398d1 | ||
|
|
f8972d3d40 | ||
|
|
5bad9ef38f | ||
|
|
5f2e9be297 | ||
|
|
4815d55467 |
116 changed files with 27321 additions and 1611 deletions
21
bun.lock
21
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
83
changelog.md
83
changelog.md
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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
238
dist/vot-min.user.js
vendored
File diff suppressed because one or more lines are too long
18154
dist/vot.user.js
vendored
18154
dist/vot.user.js
vendored
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
Статус: [✅] Работает
|
||||
|
|
|
|||
|
|
@ -286,6 +286,9 @@ const siteData = {
|
|||
niconico: {
|
||||
paths: ["/watch/VIDEO_ID"],
|
||||
},
|
||||
dropout: {
|
||||
domains: ["watch.dropout.tv"],
|
||||
},
|
||||
directlink: {
|
||||
paths: ["/*.mp4", "/*.webm"],
|
||||
limits: [locales.noLocalLinks],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ Contains the implementation for extracting the audio track from a YouTube video
|
|||
|
||||
Implemented:
|
||||
|
||||
- `web_abr`
|
||||
- `web_mse_proxy`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
16
src/audioDownloader/strategies/audioChunks.ts
Normal file
16
src/audioDownloader/strategies/audioChunks.ts
Normal 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;
|
||||
}
|
||||
25
src/audioDownloader/strategies/audioStrategy.ts
Normal file
25
src/audioDownloader/strategies/audioStrategy.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
(
|
||||
|
|
|
|||
290
src/audioDownloader/strategies/safariPageBridge.ts
Normal file
290
src/audioDownloader/strategies/safariPageBridge.ts
Normal 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);
|
||||
}
|
||||
2099
src/audioDownloader/strategies/webAbr.ts
Normal file
2099
src/audioDownloader/strategies/webAbr.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
61
src/audioDownloader/strategies/youtubePage.ts
Normal file
61
src/audioDownloader/strategies/youtubePage.ts
Normal 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;
|
||||
}
|
||||
164
src/audioDownloader/strategies/youtubePoToken.ts
Normal file
164
src/audioDownloader/strategies/youtubePoToken.ts
Normal 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;
|
||||
}
|
||||
1201
src/audioDownloader/strategies/youtubeSabr.ts
Normal file
1201
src/audioDownloader/strategies/youtubeSabr.ts
Normal file
File diff suppressed because it is too large
Load diff
230
src/audioDownloader/strategies/youtubeSabrPlayer.ts
Normal file
230
src/audioDownloader/strategies/youtubeSabrPlayer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
110
src/audioDownloader/strategies/youtubeSabrStrategy.ts
Normal file
110
src/audioDownloader/strategies/youtubeSabrStrategy.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
829
src/audioDownloader/strategies/youtubeSabrSupport.ts
Normal file
829
src/audioDownloader/strategies/youtubeSabrSupport.ts
Normal 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
|
||||
>();
|
||||
44
src/audioDownloader/strategies/youtubeWebAbr.ts
Normal file
44
src/audioDownloader/strategies/youtubeWebAbr.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
273
src/audioDownloader/strategies/ytPlayerSolver.js
Normal file
273
src/audioDownloader/strategies/ytPlayerSolver.js
Normal 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);
|
||||
88
src/audioDownloader/utils.ts
Normal file
88
src/audioDownloader/utils.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "ትርጉሙ በግምት ይወስዳል {0} ደቂቃዎች",
|
||||
"requestTranslationFailed": "የቪዲዮ ትርጉም መጠየቅ አልተሳካም",
|
||||
"audioNotReceived": "የድምፅ አገናኝ አልተቀበለም",
|
||||
"VOTYouTubeSignInSuggested": "ወደ YouTube ይግቡ እና ትርጉሙን እንደገና ይሞክሩ",
|
||||
"audioFormatNotSupported": "የድምጽ ቅርጸት አይደገፍም",
|
||||
"VOTAutoTranslate": "ክፈት ላይ መተርጎም",
|
||||
"VOTDontTranslateYourLang": "ከቋንቋዬ አትበል",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "የቀጥታ ድምጾች",
|
||||
"VOTLiveVoicesSubtitle": "ከፍተኛው ተመሳሳይነት. ሁሉም ሰው ሩሲያኛ ያውቃል ከሆነ እንደ",
|
||||
"VOTYandexTokenExpired": "ስብሰባው አብቅቷል። እንደገና ይግቡ",
|
||||
"VOTVoiceSelection": "ዱባይን ይምረጡ"
|
||||
"VOTVoiceSelection": "ዱባይን ይምረጡ",
|
||||
"VOTRetryTranslation": "ትርጉሙን እንደገና ሞክር"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "ستستغرق الترجمة تقريبا {0} دقيقة",
|
||||
"requestTranslationFailed": "فشل طلب ترجمة الفيديو",
|
||||
"audioNotReceived": "لم يتم استلام رابط الصوت",
|
||||
"VOTYouTubeSignInSuggested": "سجّل الدخول إلى YouTube وحاول الترجمة مرة أخرى",
|
||||
"audioFormatNotSupported": "تنسيق الصوت غير مدعوم",
|
||||
"VOTAutoTranslate": "الترجمة عند الفتح",
|
||||
"VOTDontTranslateYourLang": "لا تترجم من لغتي",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "أصوات حية",
|
||||
"VOTLiveVoicesSubtitle": "التشابه الأقصى. كما لو أن الجميع يعرف الروسية",
|
||||
"VOTYandexTokenExpired": "انتهت الجلسة. تسجيل الدخول مرة أخرى",
|
||||
"VOTVoiceSelection": "اختر الدبلجة"
|
||||
"VOTVoiceSelection": "اختر الدبلجة",
|
||||
"VOTRetryTranslation": "إعادة محاولة الترجمة"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Преводът ще отнеме около {0} минути",
|
||||
"requestTranslationFailed": "Неуспешно искане за превод на видео",
|
||||
"audioNotReceived": "Аудио връзката не е получена",
|
||||
"VOTYouTubeSignInSuggested": "Влезте в YouTube и опитайте превода отново",
|
||||
"audioFormatNotSupported": "Аудио форматът не се поддържа",
|
||||
"VOTAutoTranslate": "Превод на отворен",
|
||||
"VOTDontTranslateYourLang": "Не превеждайте от моя език",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "Живи гласове",
|
||||
"VOTLiveVoicesSubtitle": "Максимално сходство. Сякаш всички знаят руски.",
|
||||
"VOTYandexTokenExpired": "Сесията изтече. Влезте отново",
|
||||
"VOTVoiceSelection": "Изберете дублаж"
|
||||
"VOTVoiceSelection": "Изберете дублаж",
|
||||
"VOTRetryTranslation": "Повторен опит за превод"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "একটি অনুবাদ প্রায় {0} মিনিট সময় নেবে",
|
||||
"requestTranslationFailed": "ভিডিও অনুবাদের অনুরোধ ব্যর্থ হয়েছে",
|
||||
"audioNotReceived": "অডিও লিঙ্ক পাওয়া যায়নি",
|
||||
"VOTYouTubeSignInSuggested": "YouTube-এ সাইন ইন করুন এবং আবার অনুবাদ করার চেষ্টা করুন",
|
||||
"audioFormatNotSupported": "অডিও ফরম্যাটটি সমর্থিত নয়",
|
||||
"VOTAutoTranslate": "খোলার সময় অনুবাদ করুন",
|
||||
"VOTDontTranslateYourLang": "নিজের ভাষা অনুবাদ না করুন",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "লাইভ ভয়েসেস",
|
||||
"VOTLiveVoicesSubtitle": "সর্বাধিক সাদৃশ্য. যেন সবাই রাশিয়ান জানে",
|
||||
"VOTYandexTokenExpired": "অধিবেশন শেষ আবার লগ ইন করুন",
|
||||
"VOTVoiceSelection": "ডাবিং চয়ন করুন"
|
||||
"VOTVoiceSelection": "ডাবিং চয়ন করুন",
|
||||
"VOTRetryTranslation": "অনুবাদ আবার চেষ্টা করুন"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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ó"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Η μετάφραση θα διαρκέσει περίπου {0} λεπτά",
|
||||
"requestTranslationFailed": "Αποτυχία αίτησης μετάφρασης βίντεο",
|
||||
"audioNotReceived": "Δεν έχει ληφθεί σύνδεσμος ήχου",
|
||||
"VOTYouTubeSignInSuggested": "Συνδεθείτε στο YouTube και δοκιμάστε ξανά τη μετάφραση",
|
||||
"audioFormatNotSupported": "Η μορφή ήχου δεν υποστηρίζεται",
|
||||
"VOTAutoTranslate": "Μετάφραση σε ανοιχτό",
|
||||
"VOTDontTranslateYourLang": "Μην μεταφράζετε από τη γλώσσα μου",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "Ζωντανές φωνές",
|
||||
"VOTLiveVoicesSubtitle": "Μέγιστη ομοιότητα. Σαν να γνωρίζουν όλοι ρωσική",
|
||||
"VOTYandexTokenExpired": "Η συνεδρία έληξε. Συνδεθείτε ξανά",
|
||||
"VOTVoiceSelection": "Επιλέξτε μεταγλώττιση"
|
||||
"VOTVoiceSelection": "Επιλέξτε μεταγλώττιση",
|
||||
"VOTRetryTranslation": "Επανάληψη μετάφρασης"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "ترجمه تقریباً {0} دقیقه طول می کشد",
|
||||
"requestTranslationFailed": "امکان درخواست ترجمه ویدیو وجود ندارد",
|
||||
"audioNotReceived": "پیدا نشدن پیوند صوتی",
|
||||
"VOTYouTubeSignInSuggested": "وارد YouTube شوید و ترجمه را دوباره امتحان کنید",
|
||||
"audioFormatNotSupported": "قالب صوتی پشتیبانی نمی شود",
|
||||
"VOTAutoTranslate": "ترجمه خودکار",
|
||||
"VOTDontTranslateYourLang": "از زبان مادری خود ترجمه نکنید",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "صداهای زنده",
|
||||
"VOTLiveVoicesSubtitle": "حداکثر شباهت. گویی همه روسی می دانند",
|
||||
"VOTYandexTokenExpired": "جلسه منقضی شد. دوباره وارد شوید",
|
||||
"VOTVoiceSelection": "دوبله را انتخاب کنید"
|
||||
"VOTVoiceSelection": "دوبله را انتخاب کنید",
|
||||
"VOTRetryTranslation": "تلاش مجدد برای ترجمه"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "{0} मिनट के बारे में अनुवाद लगेगा",
|
||||
"requestTranslationFailed": "वीडियो अनुवाद का अनुरोध विफल रहा",
|
||||
"audioNotReceived": "ऑडियो का लिंक प्राप्त नहीं हुआ",
|
||||
"VOTYouTubeSignInSuggested": "YouTube में साइन इन करें और फिर से अनुवाद करने की कोशिश करें",
|
||||
"audioFormatNotSupported": "ऑडियो फ़ॉर्मेट समर्थित नहीं है",
|
||||
"VOTAutoTranslate": "खोलने पर अनुवाद करें",
|
||||
"VOTDontTranslateYourLang": "अपनी भाषा से अनुवाद न करें",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "लाइव आवाज",
|
||||
"VOTLiveVoicesSubtitle": "अधिकतम समानता। जैसे कि हर कोई रूसी जानता है",
|
||||
"VOTYandexTokenExpired": "सत्र समाप्त हो गया । फिर से लॉग इन करें",
|
||||
"VOTVoiceSelection": "डबिंग चुनें"
|
||||
"VOTVoiceSelection": "डबिंग चुनें",
|
||||
"VOTRetryTranslation": "अनुवाद का फिर से प्रयास करें"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Թարգմանությունը կտեւի մոտավորապես {0} րոպե",
|
||||
"requestTranslationFailed": "Չհաջողվեց պահանջել տեսանյութի թարգմանություն",
|
||||
"audioNotReceived": "Աուդիո հաղորդակցություն չի ստացվել",
|
||||
"VOTYouTubeSignInSuggested": "Մուտք գործեք YouTube և կրկին փորձեք թարգմանել",
|
||||
"audioFormatNotSupported": "Աուդիո Ձևաչափը չի ապահովվում",
|
||||
"VOTAutoTranslate": "Թարգմանել բաց",
|
||||
"VOTDontTranslateYourLang": "Մի թարգմանիր իմ լեզվից",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "Կենդանի ձայներ",
|
||||
"VOTLiveVoicesSubtitle": "Առավելագույն նմանություն: Կարծես բոլորը գիտեն ռուսերեն",
|
||||
"VOTYandexTokenExpired": "Նիստը լրացավ: Կրկին մուտք գործեք համակարգ",
|
||||
"VOTVoiceSelection": "Ընտրեք կրկնօրինակ"
|
||||
"VOTVoiceSelection": "Ընտրեք կրկնօրինակ",
|
||||
"VOTRetryTranslation": "Կրկին փորձել թարգմանությունը"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "翻訳には約{0}分かかります",
|
||||
"requestTranslationFailed": "動画の翻訳リクエストに失敗しました",
|
||||
"audioNotReceived": "音声リンクが受信されませんでした",
|
||||
"VOTYouTubeSignInSuggested": "YouTube にログインして、もう一度翻訳をお試しください",
|
||||
"audioFormatNotSupported": "音声形式がサポートされていません",
|
||||
"VOTAutoTranslate": "開いたときに翻訳する",
|
||||
"VOTDontTranslateYourLang": "自分の言語からは翻訳しない",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "ライブボイス",
|
||||
"VOTLiveVoicesSubtitle": "最大の類似性。 誰もがロシア語を知っているかのように",
|
||||
"VOTYandexTokenExpired": "セッションの有効期限が切れました。 もう一度ログインします",
|
||||
"VOTVoiceSelection": "ダビングを選ぶ"
|
||||
"VOTVoiceSelection": "ダビングを選ぶ",
|
||||
"VOTRetryTranslation": "翻訳を再試行"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Аударма қазірғі түрде {0} минутқа жағынан аударылатын болады",
|
||||
"requestTranslationFailed": "Бейнені аудару талап етуден кейін сәтсіз аяқталды",
|
||||
"audioNotReceived": "Аудиоға сілтеме алынған жоқ",
|
||||
"VOTYouTubeSignInSuggested": "YouTube жүйесіне кіріп, аударманы қайтадан қолданып көріңіз",
|
||||
"audioFormatNotSupported": "Аудио пішімі қолдау көрсетмейді",
|
||||
"VOTAutoTranslate": "Ашылада переводдау",
|
||||
"VOTDontTranslateYourLang": "Өз тіліңнен таратпау",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "Тірі дауыстар",
|
||||
"VOTLiveVoicesSubtitle": "Максималды ұқсастық. Барлығы орыс тілін білетін сияқты",
|
||||
"VOTYandexTokenExpired": "Сессияның мерзімі аяқталды. Қайта кіріңіз",
|
||||
"VOTVoiceSelection": "Дубляжды таңдаңыз"
|
||||
"VOTVoiceSelection": "Дубляжды таңдаңыз",
|
||||
"VOTRetryTranslation": "Аударманы қайталап көру"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "សាកល្បងបកប្រែម្តងទៀត"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "ಅನುವಾದವು ಸರಿಸುಮಾರು {0} ನಿಮಿಷಗಳನ್ನು ತೆಗೆದುಕೊಳ್ಳುತ್ತದೆ",
|
||||
"requestTranslationFailed": "ವೀಡಿಯೊ ಅನುವಾದವನ್ನು ಕೋರಲು ವಿಫಲಗೊಂಡಿದೆ",
|
||||
"audioNotReceived": "ಆಡಿಯೋ ಲಿಂಕ್ ಬಂದಿಲ್ಲ",
|
||||
"VOTYouTubeSignInSuggested": "YouTube ಗೆ ಸೈನ್ ಇನ್ ಮಾಡಿ ಮತ್ತು ಅನುವಾದವನ್ನು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ",
|
||||
"audioFormatNotSupported": "ಆಡಿಯೋ ಸ್ವರೂಪ ಬೆಂಬಲವಿಲ್ಲ",
|
||||
"VOTAutoTranslate": "ತೆರೆದ ಮೇಲೆ ಭಾಷಾಂತರಿಸಿ",
|
||||
"VOTDontTranslateYourLang": "'ನನ್ನ ಭಾಷೆಯಿಂದ ಭಾಷಾಂತರಿಸಬೇಡಿ'",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "ಲೈವ್ ಧ್ವನಿಗಳು",
|
||||
"VOTLiveVoicesSubtitle": "ಗರಿಷ್ಠ ಹೋಲಿಕೆ. ಎಲ್ಲರಿಗೂ ರಷ್ಯನ್ ತಿಳಿದಿರುವಂತೆ",
|
||||
"VOTYandexTokenExpired": "ಅಧಿವೇಶನ ಮುಗಿದಿದೆ. ಮತ್ತೆ ಲಾಗ್ ಇನ್ ಮಾಡಿ",
|
||||
"VOTVoiceSelection": "ಡಬ್ಬಿಂಗ್ ಆಯ್ಕೆ"
|
||||
"VOTVoiceSelection": "ಡಬ್ಬಿಂಗ್ ಆಯ್ಕೆ",
|
||||
"VOTRetryTranslation": "ಅನುವಾದವನ್ನು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "번역에 약 {0}분 걸립니다",
|
||||
"requestTranslationFailed": "비디오 번역 요청 실패",
|
||||
"audioNotReceived": "오디오 링크를 받지 못했습니다",
|
||||
"VOTYouTubeSignInSuggested": "YouTube에 로그인한 후 번역을 다시 시도하세요",
|
||||
"audioFormatNotSupported": "오디오 형식이 지원되지 않습니다",
|
||||
"VOTAutoTranslate": "열 때 번역하기",
|
||||
"VOTDontTranslateYourLang": "내 언어에서 번역하지 않기",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "라이브 보이스",
|
||||
"VOTLiveVoicesSubtitle": "최대 유사성. 모두가 러시아어를 알고있는 것처럼",
|
||||
"VOTYandexTokenExpired": "세션이 만료되었습니다. 다시 로그인",
|
||||
"VOTVoiceSelection": "더빙 선택"
|
||||
"VOTVoiceSelection": "더빙 선택",
|
||||
"VOTRetryTranslation": "번역 다시 시도"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "ລອງແປອີກຄັ້ງ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Преводот ќе трае приближно {0} минути",
|
||||
"requestTranslationFailed": "Не успеа да побара видео превод",
|
||||
"audioNotReceived": "Аудио врската не е примена",
|
||||
"VOTYouTubeSignInSuggested": "Најавете се на YouTube и обидете се повторно со преводот",
|
||||
"audioFormatNotSupported": "Аудио форматот не е поддржан",
|
||||
"VOTAutoTranslate": "Преведи на отворено",
|
||||
"VOTDontTranslateYourLang": "Не преведувајте од мојот јазик",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "Гласови во живо",
|
||||
"VOTLiveVoicesSubtitle": "Максимална сличност. Како секој да знае руски",
|
||||
"VOTYandexTokenExpired": "Сесијата истече. Пријавете се повторно",
|
||||
"VOTVoiceSelection": "Изберете синхронизација"
|
||||
"VOTVoiceSelection": "Изберете синхронизација",
|
||||
"VOTRetryTranslation": "Обиди се повторно со преводот"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "പരിഭാഷ ഏകദേശം {0} മിനിറ്റ് എടുക്കും",
|
||||
"requestTranslationFailed": "വീഡിയോ പരിഭാഷ ആവശ്യപ്പെടുന്നതില് പരാജയം",
|
||||
"audioNotReceived": "ഓഡിയോ ലിങ്ക് ലഭ്യമല്ല",
|
||||
"VOTYouTubeSignInSuggested": "YouTube-ൽ സൈൻ ഇൻ ചെയ്ത് വിവർത്തനം വീണ്ടും ശ്രമിക്കുക",
|
||||
"audioFormatNotSupported": "ഓഡിയോ ഫോർമാറ്റ് പിന്തുണയ്ക്കുന്നില്ല",
|
||||
"VOTAutoTranslate": "ഓപ്പണിൽ വിവർത്തനം ചെയ്യുക",
|
||||
"VOTDontTranslateYourLang": "എന്റെ ഭാഷയിൽ നിന്ന് വിവർത്തനം ചെയ്യരുത്",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "ലൈവ് ശബ്ദങ്ങൾ",
|
||||
"VOTLiveVoicesSubtitle": "പരമാവധി സമാനത. എല്ലാവരും റഷ്യൻ അറിയുന്നു പോലെ",
|
||||
"VOTYandexTokenExpired": "സെഷൻ അവസാനിച്ചു. വീണ്ടും ലോഗിൻ ചെയ്യുക",
|
||||
"VOTVoiceSelection": "ഡബ്ബിംഗ് തിരഞ്ഞെടുക്കുക"
|
||||
"VOTVoiceSelection": "ഡബ്ബിംഗ് തിരഞ്ഞെടുക്കുക",
|
||||
"VOTRetryTranslation": "വിവർത്തനം വീണ്ടും ശ്രമിക്കുക"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Орчуулга нь ойролцоогоор {0} минут болно",
|
||||
"requestTranslationFailed": "Видео орчуулга хийх хүсэлт гаргаагүй",
|
||||
"audioNotReceived": "Аудио холбоос хүлээн аваагүй",
|
||||
"VOTYouTubeSignInSuggested": "YouTube-д нэвтэрч, орчуулгыг дахин оролдоно уу",
|
||||
"audioFormatNotSupported": "Аудио форматыг дэмждэггүй",
|
||||
"VOTAutoTranslate": "Нээлттэй дээр орчуулах",
|
||||
"VOTDontTranslateYourLang": "Миний хэлнээс битгий орчуулаарай",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "Амьд дуу хоолой",
|
||||
"VOTLiveVoicesSubtitle": "Хамгийн их төстэй. Хэрэв хүн бүр мэддэг орос",
|
||||
"VOTYandexTokenExpired": "Хуралдаан дууссан. Дахин нэвтрэх",
|
||||
"VOTVoiceSelection": "Дубляж сонгох"
|
||||
"VOTVoiceSelection": "Дубляж сонгох",
|
||||
"VOTRetryTranslation": "Орчуулгыг дахин оролдох"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "ဘာသာပြန်ခြင်းကို ထပ်စမ်းကြည့်ပါ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "अनुवाद लगभग {0} मिनेट लाग्नेछ",
|
||||
"requestTranslationFailed": "भिडियो अनुवाद अनुरोध गर्न असफल भयो",
|
||||
"audioNotReceived": "अडियो लिङ्क प्राप्त भएन",
|
||||
"VOTYouTubeSignInSuggested": "YouTube मा साइन इन गर्नुहोस् र अनुवाद फेरि प्रयास गर्नुहोस्",
|
||||
"audioFormatNotSupported": "अडियो ढाँचा समर्थित छैन",
|
||||
"VOTAutoTranslate": "खुला मा अनुवाद गर्नुहोस्",
|
||||
"VOTDontTranslateYourLang": "मेरो भाषाबाट अनुवाद नगर्नुहोस्",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "प्रत्यक्ष आवाजहरू",
|
||||
"VOTLiveVoicesSubtitle": "अधिकतम समानता मानौं सबैलाई रुसी भाषा थाहा छ",
|
||||
"VOTYandexTokenExpired": "सत्र समाप्त भयो । फेरि लग इन गर्नुहोस्",
|
||||
"VOTVoiceSelection": "डबिंग रोज्नुहोस्"
|
||||
"VOTVoiceSelection": "डबिंग रोज्नुहोस्",
|
||||
"VOTRetryTranslation": "अनुवाद पुन: प्रयास गर्नुहोस्"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "ਅਨੁਵਾਦ ਲਗਭਗ {0} ਮਿੰਟ ਲਵੇਗਾ",
|
||||
"requestTranslationFailed": "ਵੀਡੀਓ ਅਨੁਵਾਦ ਲਈ ਬੇਨਤੀ ਕਰਨ ਲਈ ਫੇਲ੍ਹ ਹੈ",
|
||||
"audioNotReceived": "ਆਡੀਓ ਲਿੰਕ ਨਹੀਂ ਮਿਲਿਆ",
|
||||
"VOTYouTubeSignInSuggested": "YouTube ਵਿੱਚ ਸਾਈਨ ਇਨ ਕਰੋ ਅਤੇ ਅਨੁਵਾਦ ਦੁਬਾਰਾ ਅਜ਼ਮਾਓ",
|
||||
"audioFormatNotSupported": "ਆਡੀਓ ਫਾਰਮੈਟ ਸਹਾਇਕ ਨਹੀਂ ਹੈ",
|
||||
"VOTAutoTranslate": "ਓਪਨ ਉੱਤੇ ਅਨੁਵਾਦ ਕਰੋ",
|
||||
"VOTDontTranslateYourLang": "ਮੇਰੀ ਭਾਸ਼ਾ ਤੋਂ ਅਨੁਵਾਦ ਨਾ ਕਰੋ",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "ਲਾਈਵ ਆਵਾਜ਼ਾਂ",
|
||||
"VOTLiveVoicesSubtitle": "ਵੱਧ ਤੋਂ ਵੱਧ ਸਮਾਨਤਾ ਜਿਵੇਂ ਕਿ ਹਰ ਕੋਈ ਰੂਸੀ ਜਾਣਦਾ ਹੈ",
|
||||
"VOTYandexTokenExpired": "ਸੈਸ਼ਨ ਖਤਮ ਹੋ ਗਿਆ ਹੈ. ਮੁੜ ਲਾਗਇਨ ਕਰੋ",
|
||||
"VOTVoiceSelection": "ਡਬਿੰਗ ਚੁਣੋ"
|
||||
"VOTVoiceSelection": "ਡਬਿੰਗ ਚੁਣੋ",
|
||||
"VOTRetryTranslation": "ਅਨੁਵਾਦ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Перевод займёт примерно {0} минуты",
|
||||
"requestTranslationFailed": "Не удалось запросить перевод видео",
|
||||
"audioNotReceived": "Не получена ссылка на аудио",
|
||||
"VOTYouTubeSignInSuggested": "Войдите в YouTube и повторите перевод",
|
||||
"audioFormatNotSupported": "Формат аудио не поддерживается",
|
||||
"VOTAutoTranslate": "Переводить при открытии",
|
||||
"VOTAutoSubtitles": "Субтитры при открытии",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"subtitlesSmartLayout": "Умное расположение субтитров",
|
||||
"smartDucking": "Адаптивная громкость",
|
||||
"VOTYandexTokenExpired": "Сессия истекла. Войдите снова",
|
||||
"VOTVoiceSelection": "Выбрать озвучку"
|
||||
"VOTVoiceSelection": "Выбрать озвучку",
|
||||
"VOTRetryTranslation": "Повторить перевод"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "පරිවර්තනය සඳහා ආසන්න වශයෙන් {0} විනාඩි ගතවනු ඇත",
|
||||
"requestTranslationFailed": "වීඩියෝ පරිවර්තනය ඉල්ලා සිටීමට අසමත්",
|
||||
"audioNotReceived": "ශ්රව්ය සබැඳිය ලැබී නැත",
|
||||
"VOTYouTubeSignInSuggested": "YouTube වෙත පුරනය වී පරිවර්තනය නැවත උත්සාහ කරන්න",
|
||||
"audioFormatNotSupported": "ශ් රව් ය ආකෘතිය සහාය නොදක්වයි",
|
||||
"VOTAutoTranslate": "විවෘත මත පරිවර්තනය කරන්න",
|
||||
"VOTDontTranslateYourLang": "මගේ භාෂාවෙන් පරිවර්තනය කරන්න එපා",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "සජීවී හඬවල්",
|
||||
"VOTLiveVoicesSubtitle": "උපරිම සමානතාවය. හැමෝම රුසියානු දන්නවා වගේ",
|
||||
"VOTYandexTokenExpired": "සැසිය කල් ඉකුත් විය. නැවත පිවිසෙන්න",
|
||||
"VOTVoiceSelection": "ඩබ් කිරීම තෝරන්න"
|
||||
"VOTVoiceSelection": "ඩබ් කිරීම තෝරන්න",
|
||||
"VOTRetryTranslation": "පරිවර්තනය නැවත උත්සාහ කරන්න"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Превод ће трајати приближно {0} минута",
|
||||
"requestTranslationFailed": "Захтев за превод видео записа није успео",
|
||||
"audioNotReceived": "Аудио комуникација није примљена",
|
||||
"VOTYouTubeSignInSuggested": "Пријавите се на YouTube и покушајте поново са преводом",
|
||||
"audioFormatNotSupported": "Аудио формат није подржан",
|
||||
"VOTAutoTranslate": "Преведи на отворено",
|
||||
"VOTDontTranslateYourLang": "Не преводите са мог језика",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "Живи гласови",
|
||||
"VOTLiveVoicesSubtitle": "Максимална сличност. Као да сви знају руски",
|
||||
"VOTYandexTokenExpired": "Сесија је истекла. Поново се пријавите",
|
||||
"VOTVoiceSelection": "Изаберите дуб"
|
||||
"VOTVoiceSelection": "Изаберите дуб",
|
||||
"VOTRetryTranslation": "Покушај превод поново"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "Переклад займе приблизно {0} хвилини",
|
||||
"requestTranslationFailed": "Не вдалося запросити Переклад відео",
|
||||
"audioNotReceived": "Аудіосвязь не отримана",
|
||||
"VOTYouTubeSignInSuggested": "Увійдіть у YouTube і повторіть переклад",
|
||||
"audioFormatNotSupported": "Аудіоформат не підтримується",
|
||||
"VOTAutoTranslate": "Перевести на відкритий",
|
||||
"VOTDontTranslateYourLang": "Не перекладайте з моєї мови",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "Живі голоси",
|
||||
"VOTLiveVoicesSubtitle": "Максимальна схожість. Наче всі знають російську",
|
||||
"VOTYandexTokenExpired": "Сеанс закінчився. Увійдіть ще раз",
|
||||
"VOTVoiceSelection": "Виберіть дубляж"
|
||||
"VOTVoiceSelection": "Виберіть дубляж",
|
||||
"VOTRetryTranslation": "Повторити переклад"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "ترجمہ تقریباً {0} منٹ لیگا",
|
||||
"requestTranslationFailed": "ویڈیو ترجمہ کرنے میں ناکامی",
|
||||
"audioNotReceived": "آڈیو کا لنک حاصل نہیں ہوا",
|
||||
"VOTYouTubeSignInSuggested": "YouTube میں سائن ان کریں اور دوبارہ ترجمہ کرنے کی کوشش کریں",
|
||||
"audioFormatNotSupported": "آڈیو فارمیٹ کی حمایت نہیں کی جاتی",
|
||||
"VOTAutoTranslate": "کھولنے پر ترجمہ کریں",
|
||||
"VOTDontTranslateYourLang": "اپنی زبان سے ترجمہ نہ کریں",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "لائیو آوازیں",
|
||||
"VOTLiveVoicesSubtitle": "زیادہ سے زیادہ مماثلت. گویا ہر کوئی روسی جانتا ہے ۔ ",
|
||||
"VOTYandexTokenExpired": "سیشن ختم ہو گیا. دوبارہ لاگ ان کریں",
|
||||
"VOTVoiceSelection": "ڈبنگ کا انتخاب کریں"
|
||||
"VOTVoiceSelection": "ڈبنگ کا انتخاب کریں",
|
||||
"VOTRetryTranslation": "ترجمہ دوبارہ آزمائیں"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"translationTakeApproximatelyMinute": "翻译大约需要{0}分钟",
|
||||
"requestTranslationFailed": "请求视频翻译失败",
|
||||
"audioNotReceived": "未收到音频链接",
|
||||
"VOTYouTubeSignInSuggested": "登录 YouTube 后重试翻译",
|
||||
"audioFormatNotSupported": "不支持音频格式",
|
||||
"VOTAutoTranslate": "打开翻译",
|
||||
"VOTDontTranslateYourLang": "不要从我的语言翻译",
|
||||
|
|
@ -251,5 +252,6 @@
|
|||
"VOTLiveVoicesTitle": "现场声音",
|
||||
"VOTLiveVoicesSubtitle": "最大相似度。 好像每个人都知道俄语",
|
||||
"VOTYandexTokenExpired": "会话过期。 再次登入",
|
||||
"VOTVoiceSelection": "选择配音"
|
||||
"VOTVoiceSelection": "选择配音",
|
||||
"VOTRetryTranslation": "重试翻译"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue