Publish YouTubeDL 1.4.5 and website on Gitea
Check extension and helper / check (push) Canceled after 0s

This commit is contained in:
DepressedCat committed 2026-10-10 23:53:17 +03:00
commit 5f2aad00f4
97 files changed
+8451

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: Check extension and helper
on:
push:
branches: [main]
paths-ignore: ['site/**', 'docs/**', '*.md']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
check:
runs-on: windows-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7.1.0
with:
node-version: '24'
cache: npm
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.13'
- run: python -m pip install -r requirements.txt
- run: npm ci
- run: npm run check
- run: npm test
- run: python -m unittest discover -s tests -v
- run: python tools/build.py
+13
View File
@@ -0,0 +1,13 @@
.runtime/
__pycache__/
*.pyc
test-artifacts/
dist/
node_modules/
.build/
.build-cache/
.venv/
extension/helper-installer.exe
.env
.env.*
*.log
+87
View File
@@ -0,0 +1,87 @@
# Сборка и проверки правообладателя
Этот документ описывает обслуживание официальной версии правообладателем и уполномоченными им лицами.
Публикация инструкций не предоставляет права изменять, создавать самостоятельные форки
или распространять новые версии собственного кода. Условия и исключения — в [LICENSE](LICENSE).
Пользователь может собрать неизменённую официальную поставку для установки и использования по назначению.
Исходники расширения — `extension/`, Native Messaging помощника — `host/`,
установщика — `tools/setup.cs`, сайта — `site/`.
Готовые EXE, приватный runtime, кеши, личная история и `node_modules` в Git не включаются.
## Быстрые проверки
Нужны Python 3.10+ и Node.js 22+. Для тестов помощника установите `requirements.txt`
в отдельное виртуальное окружение. Не меняйте глобальное окружение пользователя.
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
npm ci
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
npm test
npm run check
python tools/build.py
```
`tools/build.py` собирает небольшой `dist/YouTubeDL-1.4.5.zip` без EXE.
Для сборки расширения не требуется предварительно собирать помощник.
## Браузерные проверки
Нужен установленный Microsoft Edge в Windows; для `test:ui` можно задать `YTDL_TEST_BROWSER`.
Скриншоты и отчёты сохраняются в игнорируемую папку `test-artifacts`.
```powershell
npm run test:ui
npm run test:setup
npm run test:thumbnails
npm run test:motion
npm run test:live-shorts
npm run test:site
```
Проверки UI используют контролируемые браузерные API. `test:motion` и `test:live-shorts`
загружают настоящее расширение в отдельный профиль Edge; последняя открывает живой YouTube.
`test:site` проверяет сайт в браузере на широком и узком экране, ссылки скачивания, темы и навигацию.
`npm run test:reconnect` использует отдельную установку и уникальное имя native host:
проверяет install/remove/reinstall, разрывы портов и две открытые панели без перезагрузки.
Ему нужен `dist/YouTubeDL-Helper-Setup.exe`.
`test:browser` скачивает публичный тестовый ролик и проверяет NVENC через FFprobe.
**`test:helper` удаляет и повторно устанавливает зарегистрированный помощник текущего пользователя**:
запускайте только в подготовленной тестовой учётной записи Windows.
`tests/offline-bundle.py`, `tests/component-update.py` и `tests/amd-fallback.py` проверяют автономность,
обновления и отказ AMD с переходом на CPU. GPU-проверки зависят от реального оборудования.
## Сборка помощника
Нужны Windows x64, C# compiler .NET Framework и Python. Подготовка требует интернет,
работает в `.build-cache` и `.build`, проверяет закреплённые SHA256.
```powershell
python tools/build_bundle.py --prepare
# Повторная сборка из подготовленного комплекта:
python tools/build_bundle.py
```
Версия помощника берётся из `host/common.py`, версия расширения — из `extension/manifest.json`.
Не меняйте публичный `key` расширения: он закрепляет ID, разрешённый установленному native host.
EXE самодостаточен; `install.ps1` / `install.cmd` предназначены для разработки с внешним Python.
## Релизы и сайт
- Публикуйте ZIP расширения, отдельный `YouTubeDL-Helper-Setup.exe`, манифест, лицензии/исходные материалы и `SHA256SUMS.txt` в релизах Gitea.
- Перед выпуском обновите версии и ссылки в `INSTALL.md`, `README.md`, `site/index.html` и документации. Стабильное имя EXE позволяет использовать URL `/releases/download/vВЕРСИЯ/YouTubeDL-Helper-Setup.exe`.
- Исходники статического сайта находятся в `site/` того же репозитория. Nginx публикует их по адресу https://git.lonarpgproxy.space/extention/youtubedl/; обновление выполняется из проверенного коммита main отдельным развёртыванием.
- Для локального просмотра: `python -m http.server 4173 --directory site`, затем `http://127.0.0.1:4173`.
- Графика и её промпты описаны в `docs/ASSETS.md`. Не добавляйте в маркетинговые скриншоты настоящие cookies, приватные ссылки или личные пути.
Для подготовки исходников FFmpeg и его библиотек используйте закреплённую копию
FFmpeg-Builds из архива исходных материалов релиза. На Linux с Docker выполните
`./download.sh win64 gpl-shared`; параметры и скачиваемые ревизии заданы в сценариях этой копии.
Проверяйте лицензии и доступность соответствующих исходников при замене стороннего runtime.
Предложения улучшений и ошибки присылайте через Issues; для изменений собственного кода
нужно предварительное письменное разрешение правообладателя. В bug report укажите версии ОС, браузера,
расширения и помощника, действия для воспроизведения и текст ошибки без личных данных.
+80
View File
@@ -0,0 +1,80 @@
# Установка YouTubeDL
Нужны **Windows 10/11 x64** и **Microsoft Edge или Google Chrome 120+**.
Firefox, macOS, Linux и Windows ARM не входят в эту сборку.
Скачивания доступны в [релизах проекта](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest).
Помощник распространяется **отдельно**: внутри архива расширения EXE нет.
1. Скачайте **YouTubeDL-1.4.5.zip** и распакуйте его в постоянную папку. Не удаляйте и не перемещайте её после установки.
2. Откройте `edge://extensions` или `chrome://extensions`. Включите **Режим разработчика**, нажмите **Загрузить распакованное расширение** и выберите папку **extension** внутри распакованного архива.
3. На странице релиза отдельно скачайте **YouTubeDL-Helper-Setup.exe**. Это автономный комплект: Python, yt-dlp, EJS, Node.js, FFmpeg и FFprobe уже внутри.
4. Запустите EXE вручную и дождитесь окончания установки. Администратор и интернет для установки комплекта не требуются. Программы устанавливаются для текущего пользователя в `%LOCALAPPDATA%\YouTubeDL\Helper`.
5. Обновите вкладку YouTube, откройте **⚙** рядом с кнопкой скачивания или значок расширения. Мастер первого запуска проверит помощник и предложит выбрать папку, качество и формат.
6. Если меню было открыто во время установки, нажмите **Проверить подключение** в мастере или **Запустить** в обычном меню. Зелёный статус означает реальный ответ помощника. Перезагрузка страницы для переподключения не нужна.
Загрузчик использует сохранённые параметры. Кнопка **Скачать** запускает загрузку, а **⚙** открывает настройки.
На превью и в Shorts доступны эти же действия в виде значков.
## Проверка скачанных файлов
Скачивайте файлы из релизов `git.lonarpgproxy.space/OwnerLink/YouTubeDL`.
Чтобы проверить целостность файла, сравните его SHA256 с `SHA256SUMS.txt` в том же релизе.
```powershell
Get-FileHash -Algorithm SHA256 -LiteralPath '.\YouTubeDL-Helper-Setup.exe'
```
## Обновления
- **Расширение:** скачайте новый ZIP из Releases, распакуйте поверх прежней папки, нажмите кнопку перезагрузки на странице расширений и обновите вкладки YouTube. Автоматической установки ZIP нет.
- **Сам помощник:** скачайте новый EXE из Releases и запустите его вручную. Кнопки установки и восстановления открывают страницу Releases.
- **Обновление компонентов** проверяет официальные источники и обновляет Python в ветке 3.13, Node.js в ветке 24 LTS, yt-dlp с зависимостями, EJS и FFmpeg с FFprobe. Запустить его вручную можно кнопкой «Обновить компоненты». По умолчанию такая проверка выполняется раз в сутки, пока помощник работает, между операциями. Её можно выключить в меню. Нужен интернет.
## Удаление
Нажмите **Удалить** в меню помощника или удалите YouTubeDL Helper в установленных приложениях Windows,
затем удалите расширение на странице управления браузера.
Скачанные ролики и локальная история помощника сохраняются.
Чтобы удалить и историю после удаления помощника, удалите `%LOCALAPPDATA%\YouTubeDL\Helper\data`.
Настройки расширения удаляются вместе с расширением. Подробнее — [PRIVACY.md](PRIVACY.md).
## Если что-то не работает
| Симптом | Что проверить |
| --- | --- |
| Помощник не установлен | Скачан и запущен именно `YouTubeDL-Helper-Setup.exe`, а не только ZIP. Установка выполнена в том же пользователе Windows, где открыт браузер. |
| Помощник не отвечает | Нажмите «Запустить». При повреждении комплекта скачайте EXE из Releases и переустановите. Не удаляйте `key` из manifest.json: ID расширения должен быть `ndnoeiaecgmjnjneekaeapeecmmjheli`. |
| Нет кнопок на YouTube | Перезагрузите расширение, затем обновите вкладку. YouTube меняет разметку; приложите к issue адрес страницы, версии браузера и расширения. |
| YouTube просит подтвердить вход | Откройте ролик в текущем профиле, пройдите проверку сайта. При необходимости включите «Использовать вход в YouTube» в меню и разрешите необязательный доступ к cookies. Это не гарантирует доступ к любому ролику. |
| GPU недоступен | Проверьте драйвер, список проверенных кодеков и режим «Авто». В режиме «Авто» возможен переход на CPU. Принудительный GPU-режим сообщает об ошибке. |
| Скачивание медленное | GPU ускоряет перекодирование видео; скорость сети и ограничения YouTube от видеокарты не зависят. |
Не прикладывайте cookies, токены, личные ссылки и полные пути с именем пользователя к публичным обращениям.
Сообщить об ошибке: [Задачи проекта](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/issues).
## Диски и временные файлы
В меню ⚙ поле «Сохранить в» задаёт папку готового файла. Поле «Рабочая папка для загрузки и обработки» задаёт место для скачанных дорожек и временных файлов FFmpeg. Оба поля допускают выбор папки на любом доступном диске. Если рабочая папка пуста, используется папка сохранения. Настройки действуют для следующих загрузок.
При разных дисках результат сначала копируется во временную папку на диске сохранения, затем публикуется под итоговым именем. Прогресс копирования виден в уведомлении; частичный файл не появляется под готовым именем. При отмене помощник завершает группу процессов данной задачи и удаляет её временные папки.
Для отдельной рабочей папки, подробного прогресса слияния и исправленной отмены установите помощник 1.3.2 и расширение 1.4.5. Обновление компонентов не заменяет программный код помощника; для этой версии запустите новый установщик помощника.
## История и ошибки
Кнопка **«История загрузок и ошибок»** под настройками открывает отдельную вкладку. Раздел **«Загрузки»** показывает завершённые и отменённые скачивания, дату, имя, размер и путь файла; **«Показать файл»** открывает его в проводнике через помощник. Раздел **«Ошибки»** содержит сообщения о сбоях скачивания, получения качества, установки и обновления компонентов.
История хранится локально в браузере: до 30 последних операций. Просмотр работает и без помощника, новые результаты появляются без перезагрузки страницы. Текущие операции и прогресс остаются в настройках и уведомлениях.
## Совместимость версий
В настройках и мастере первого запуска отображаются версии расширения и помощника. Расширение 1.4.5 поддерживает помощник 1.3.2. При старом помощнике появится кнопка «Обновить помощник», при более новом помощнике или устаревшем расширении — «Обновить расширение». Пока совместимость не подтверждена, новая загрузка не запускается.
После установки помощника нажмите «Проверить»: статус изменится без перезагрузки страницы. После замены файлов расширения нажмите «Обновить» на странице расширений браузера и перезагрузите вкладку YouTube. Обновление компонентов обновляет инструменты внутри комплекта и не заменяет само расширение или код помощника.
## Когда работает видеокарта
Видеокарта используется при перекодировании видео. В режиме «Авто» совместимое видео сохраняется без перекодирования: уведомление показывает «Подготовка файла · без перекодирования». Слияние дорожек и финализация файла также не требуют видеокодировщика.
Для проверки выберите поддерживаемый кодек, например H.264 или HEVC, и «NVIDIA NVENC» в поле ускорения. При обработке появится «Перекодирование · NVIDIA NVENC». В диспетчере задач откройте «Производительность» → GPU и выберите график Video Encode / «Кодирование видео». Если обрабатывается только звук, уведомление покажет «Обработка аудио · CPU».
+76
View File
@@ -0,0 +1,76 @@
ОГРАНИЧЕННАЯ ПОЛЬЗОВАТЕЛЬСКАЯ ЛИЦЕНЗИЯ YOUTUBEDL
Версия 1.0, 8 октября 2026 года
Copyright (c) 2026 OwnerLink. Все права сохранены.
Правообладатель: OwnerLink (https://git.lonarpgproxy.space/OwnerLink).
1. ОБЛАСТЬ ДЕЙСТВИЯ
Эта лицензия относится к собственным файлам проекта YouTubeDL, предоставляемым
с этой лицензией: самому браузерному расширению, собственному коду локального
помощника, установщика и инструментов, документации, сайту и оригинальным
материалам оформления (далее — «Программа»).
Исключения для сторонних компонентов и прав платформы
указаны в разделе 4 и имеют приоритет над ограничениями раздела 3.
2. РАЗРЕШЁННОЕ ИСПОЛЬЗОВАНИЕ
Правообладатель предоставляет бесплатное, неисключительное, непередаваемое
разрешение скачивать официальную поставку, устанавливать и запускать Программу
на принадлежащих пользователю или администрируемых им компьютерах только
по назначению: получать доступные пользователю видео и аудио с YouTube,
выбирать дорожки и параметры, объединять, конвертировать и сохранять файлы
при наличии права сохранять соответствующий контент.
Разрешены необходимые для этого установочные и резервные копии, сборка
неизменённой официальной поставки для собственного использования, настройка
через штатные средства, установка официальных обновлений и удаление Программы.
Это не предоставляет права изменять собственные файлы кода и дизайна.
3. ЗАПРЕТЫ
Без предварительного письменного разрешения правообладателя запрещается:
- изменять, адаптировать, переводить код или оформление Программы либо
создавать производные версии;
- создавать и развивать самостоятельные форки, ветки разработки и проекты
на основе собственного кода Программы;
- копировать или извлекать собственный код и оформление для других проектов;
- распространять, перепубликовывать, предоставлять третьим лицам, продавать,
сдавать в аренду или сублицензировать Программу, её изменённые версии либо
существенные части, кроме копий, прямо разрешённых разделом 2;
- удалять или заменять уведомления об авторстве, правообладателе и лицензии.
Все права, прямо не предоставленные этой лицензией, сохранены за правообладателем.
Для отдельного разрешения обращайтесь к OwnerLink через профиль Gitea проекта.
4. ИСКЛЮЧЕНИЯ
4.1. Python, Node.js, yt-dlp, EJS, FFmpeg, FFprobe и другие сторонние компоненты,
их исходники и лицензионные тексты регулируются лицензиями их правообладателей.
Эта лицензия не ограничивает предоставленные ими права, в том числе права
на изменение, форки и распространение по GPL, MIT и другим лицензиям.
Состав и сведения о лицензиях приведены в THIRD_PARTY.md и материалах релиза.
4.2. Публикация репозитория на GitHub сопровождается правами, предусмотренными
условиями GitHub. В частности, для публичных репозиториев GitHub предусматривает
просмотр и создание копий через функцию fork внутри платформы. Эта лицензия
не отменяет эти права и не является технической блокировкой функции fork.
Ограничения раздела 3 действуют в части, не покрытой отдельно предоставленными
правами платформы и применимого законодательства.
Условия: https://docs.github.com/en/site-policy/github-terms/github-terms-of-service
4.3. Права, которые применимое законодательство не позволяет ограничивать
договором, сохраняются. Эта лицензия не предоставляет прав на чужой контент
YouTube и не заменяет правила соответствующих сервисов.
5. ГАРАНТИИ И ОТВЕТСТВЕННОСТЬ
Программа предоставляется «как есть», без гарантий работоспособности,
пригодности для определённой цели, постоянного доступа к YouTube или наличия
конкретного качества и аппаратного ускорения. В пределах, допускаемых
применимым законодательством, правообладатель не отвечает за убытки,
потерю данных и другие последствия использования Программы.
При нарушении условий этой лицензии предоставленное ею разрешение прекращается;
это не прекращает права, отдельно сохраняемые разделом 4.
+59
View File
@@ -0,0 +1,59 @@
# Обработка данных
Актуально для расширения **1.4.5** и помощника **1.3.2**. Исходники доступны для просмотра,
условия использования собственного кода ограничены [лицензией](LICENSE).
У проекта нет собственного сервера обработки видео, аккаунтов, телеметрии, рекламы или аналитики.
Это не означает отсутствие обработки данных: необходимые сведения используются на вашем компьютере,
а запросы к YouTube и источникам обновлений идут через интернет.
## Какие данные используются
| Данные | Для чего и где |
| --- | --- |
| Ссылка на выбранный ролик, ID, название, канал, доступные дорожки | Получение качества и скачивание. Расширение передаёт выбранную ссылку локальному помощнику через Native Messaging. Метаданные обрабатываются в памяти; завершённые операции попадают в локальную историю. |
| Качество, формат, кодек, папка, своё имя файла, тема и состояние мастера | Сохранённые настройки в `chrome.storage.local` текущего профиля. Синхронизация через `storage.sync` не используется. |
| История и состояние операций | Расширение сохраняет до 30 последних операций в `chrome.storage.local`: название, ссылку, состояние, результат, путь и текст ошибки при её наличии. Помощник отдельно сохраняет до 30 успешно завершённых загрузок в своей папке данных для истории и «Показать файл». Разработчику эти записи не отправляются. |
| Название GPU, проверенные кодеки, версии компонентов | Проверка локального оборудования и программ, выбор рабочего режима конвертации и показ статуса. Не отправляются разработчику. |
| Настройка и время проверки обновлений | Локальный `update-settings.json` помощника. По умолчанию автоматическое обновление включено. |
| Cookies YouTube — только после согласия | Необязательная авторизация загрузчика в текущем профиле. Передаются локальному помощнику, затем используются yt-dlp в запросах к YouTube. Не пишутся в файлы cookies, настройки или историю; остаются в памяти на время обработки запроса. |
Скачивание и обработка выполняются в выбранной рабочей папке, в подпапках `.ytdl-*`.
При сохранении результата на другом диске временный файл создаётся также в папке сохранения.
Помощник очищает их по окончании операции; после аварийного завершения остатки могут сохраниться.
Готовые видео и аудио остаются там, куда вы указали.
## Разрешения расширения
| Разрешение | Назначение |
| --- | --- |
| `nativeMessaging` | Связь с установленным локальным помощником. Браузер запускает его по зарегистрированному имени; HTTP-сервер на локальном порту для этой связи не нужен. |
| `storage` | Параметры, локальное состояние операций и мастера. |
| `notifications` | Уведомления о ходе и результате операций. |
| `activeTab` | Получение адреса активной вкладки по действию пользователя. |
| Доступ к `www.youtube.com`, `m.youtube.com`, `music.youtube.com` | Кнопки и работа со ссылками YouTube. Скрипты кнопок запускаются на www/m; Music-ссылки можно открыть через окно расширения. |
| **Необязательные** `cookies` и `https://*.youtube.com/*` | Включаются только кнопкой «Использовать вход в YouTube». Код выбирает cookies домена YouTube, включая HttpOnly, и не читает Google cookies, пароли или файлы профиля. |
Chrome выдаёт разрешение `cookies` как браузерную возможность, а не отдельное разрешение на каждый cookie.
В этой реализации область чтения дополнительно ограничена доменом YouTube в коде.
Кнопка **Отключить** отзывает необязательное разрешение и прекращает передачу входа в новые операции.
Уже начатая операция может завершить запрос с переданным ранее состоянием входа.
## Кому идут сетевые запросы
- **YouTube и используемые им медиасерверы:** ссылки, запросы метаданных и скачивания; при включённом входе — cookies YouTube. Эти сервисы видят ваш IP и обрабатывают запросы по своим правилам.
- **Официальные источники компонентов:** `python.org`, `nodejs.org`, GitHub и его серверы релизов, `pypi.org` и `files.pythonhosted.org`. Запросы возникают при ручном или автоматическом обновлении. Передаются обычные сведения сетевого запроса, необходимые для скачивания; история роликов и cookies туда не отправляются.
- **Сайт и Gitea проекта:** переходы к сайту, репозиторию, релизам и задачам на `git.lonarpgproxy.space`. Сервер сохраняет стандартные журналы запросов: IP, время, запрошенный адрес, статус ответа и User-Agent браузера. История загрузок видео, локальные пути и cookies YouTube туда не отправляются. Статический сайт не содержит счётчиков, внешних шрифтов, рекламных SDK или собственных cookies. Тема сайта сохраняется в `localStorage` браузера. Gitea использует cookies для собственных сеансов входа; они не относятся к сессии YouTube. Запросы к GitHub по-прежнему возможны при обновлении сторонних компонентов с их официальных источников.
Установка первоначального EXE после скачивания работает без интернета. Само скачивание роликов требует интернет.
Нет отдельного «облачного» сервиса YouTubeDL, куда отправляется ролик для конвертации.
## Где хранятся данные и как их удалить
Обычная установка: `%LOCALAPPDATA%\YouTubeDL\Helper`, пользовательские данные — подпапка `data`.
При работе из исходников используется локальная `.runtime` проекта.
Удаление помощника сохраняет скачанные файлы и историю. После удаления помощника можно удалить его `data`,
если история тоже не нужна. Удаление расширения удаляет его локальные настройки в данном профиле браузера.
Очищать скачанные файлы нужно в выбранной вами папке.
Публичные задачи и вложения в Gitea проекта видны другим людям. Перед отправкой отчёта уберите cookies,
токены, приватные ссылки и личные пути. Текст ошибки и скриншот не отправляются автоматически.
+117
View File
@@ -0,0 +1,117 @@
<p align="center"><img src="site/assets/github-cover.png" alt="YouTubeDL — Видео. Звук. Ваш выбор." width="100%"></p>
# YouTubeDL
Скачивайте видео и Shorts с YouTube прямо из Edge или Chrome. Выберите качество,
формат и папку один раз: **загрузчик** использует сохранённые настройки,
а **⚙ рядом** открывает меню. Обработка и объединение дорожек выполняются на вашем компьютере.
**[Сайт проекта](https://git.lonarpgproxy.space/extention/youtubedl/)** ·
**[Скачать расширение и помощник](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest)** ·
**[Инструкция](INSTALL.md)** · **[Обработка данных](PRIVACY.md)** ·
**[Сообщить об ошибке](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/issues)**
Windows 10/11 x64 · Microsoft Edge / Google Chrome 120+ · расширение **1.4.5** · помощник **1.3.2**.
Расширение устанавливается распакованным, через режим разработчика. В магазинах браузеров пока не опубликовано.
## Что умеет
- **Кнопки там, где вы смотрите:** рядом с «Подписаться», на превью обычных видео, в полках Shorts и открытом Shorts-плеере.
- **Видео со звуком, только видео или только аудио.** Раздельная звуковая дорожка автоматически скачивается и добавляется к видео.
- **Выбор дорожек:** доступные разрешения, FPS, HDR и языки аудио. 4K/8K доступны, если есть у источника; разрешение искусственно не увеличивается.
- **Форматы видео:** MP4, MKV, WebM. **Аудио:** M4A/AAC, MP3, Opus, FLAC, WAV. Исходный кодек или конвертация в H.264, HEVC, AV1, VP9 с учётом совместимости формата.
- **Отдельные рабочая папка и папка сохранения**, в том числе на разных дисках. В рабочей папке скачиваются дорожки и выполняется обработка; готовый результат сохраняется в выбранную папку.
- **Прогресс FFmpeg:** процент, обработанное время, записанные мегабайты, скорость и примерный остаток. Отмена завершает процессы только соответствующей задачи.
- **Проверка совместимости версий:** расширение показывает свою версию и версию помощника. Если нужно обновить один из компонентов, сообщает какой и открывает страницу релизов.
- **Своя папка и имя файла.** При совпадении имени добавляется `(1)`, `(2)` и т. д.; готовые файлы не перезаписываются.
- **Две операции одновременно**, отмена, прогресс подготовки/скачивания/объединения/конвертации и «Показать файл».
- **Отдельная страница истории:** вкладки «Загрузки» и «Ошибки», дата, имя, размер и путь готового файла. Кнопка в настройках открывает её в новой вкладке; история доступна без помощника и обновляется при завершении операций.
- **Настройка при первом запуске**, адаптивная светлая/тёмная тема, уведомления с анимациями и поддержкой уменьшения движения.
- **Реальная проверка помощника, компонентов и GPU**, ручное и автоматическое обновление встроенных компонентов.
- **Необязательный вход в YouTube:** после отдельного согласия используется сессия текущего профиля браузера, без экспорта cookies в файл.
<p align="center"><img src="site/assets/menu-dark.png" alt="Настройки YouTubeDL: качество, звук, формат и папка" width="440"></p>
Скриншот показывает настоящий интерфейс с тестовыми метаданными.
Возможности конкретного ролика определяет YouTube. Плейлисты, пакетное скачивание каналов,
DRM-контент и гарантированный обход ограничений YouTube не заявляются.
## Установка: два отдельных скачивания
1. Откройте [последний релиз](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest), скачайте **YouTubeDL-1.4.5.zip** и распакуйте в постоянную папку.
2. В `edge://extensions` или `chrome://extensions` включите **Режим разработчика**, нажмите **Загрузить распакованное расширение** и выберите папку **extension**.
3. **Отдельно скачайте YouTubeDL-Helper-Setup.exe из того же релиза** и запустите его вручную. EXE не встроен в расширение: кнопка установки открывает страницу релизов.
4. Обновите YouTube, откройте **⚙** и пройдите мастер. Выберите папку и параметры; зелёный статус подтверждает связь с помощником.
Все компоненты уже находятся в EXE: Python, yt-dlp, EJS, Node.js, FFmpeg и FFprobe.
Для установки после скачивания интернет не требуется. Администратор не нужен;
комплект устанавливается в `%LOCALAPPDATA%\YouTubeDL\Helper` для текущего пользователя.
Системный PATH и глобальные Python/Node.js не меняются.
Контрольные суммы скачанных файлов можно сверить с `SHA256SUMS.txt` в том же релизе.
Подробные шаги, обновление и удаление — в **[INSTALL.md](INSTALL.md)**.
## Что даёт видеокарта
YouTubeDL определяет оборудование Windows и проверяет доступность кодировщиков пробным запуском FFmpeg.
Поддерживаются **NVIDIA NVENC** и **AMD AMF** для H.264, HEVC и AV1, если их поддерживает конкретная карта/драйвер.
У GeForce RTX 20/30 аппаратное кодирование доступно для H.264 и HEVC; у RTX 40/50 — также для AV1. Возможности конкретной модели приведены в [матрице NVIDIA](https://developer.nvidia.com/video-encode-decode-support-matrix).
Для AMD отдельно проверяется 10-битное кодирование HEVC/AV1. В «Авто» при необходимости используется CPU;
принудительный режим сообщает об ошибке GPU.
GPU ускоряет **перекодирование видео**, а не интернет. Совместимые дорожки копируются без перекодирования —
это обычно самый быстрый вариант. Аудио и VP9 обрабатываются на CPU.
Уведомление отдельно показывает слияние дорожек, подготовку файла без перекодирования, кодирование через NVIDIA NVENC/AMD AMF или CPU и финализацию файла. При проверке нагрузки в диспетчере задач откройте «Производительность» → GPU и выберите график **Video Encode / Кодирование видео**.
HDR → H.264 без тонального преобразования отклоняется, чтобы не испортить цвета.
FLAC/WAV не восстанавливают качество, уже потерянное в исходном аудио.
NVENC проверен на **RTX 5080**. Поддержка AMD реализована, но успешное AMF-кодирование
на физической AMD-карте пока не проверено: проверены выбор команд, отказ и переход на CPU.
## Данные и приватность
Нет аналитики, рекламы, телеметрии и сервера разработчика для обработки роликов.
**Локально сохраняются** настройки и до 30 последних операций в браузере
(включая названия, ссылки, статусы, пути файлов и ошибки). Помощник отдельно хранит
до 30 успешно завершённых загрузок. Название GPU и версии компонентов проверяются для работы приложения.
Cookies YouTube читаются **только после включения необязательного разрешения**.
Передаются локальному помощнику через Native Messaging и используются в запросах yt-dlp к YouTube;
не сохраняются в cookies-файл, настройки или историю. Google cookies, пароли и файлы профиля не читаются.
Это чувствительные данные входа: включайте доступ только если он нужен. Его можно отозвать в меню.
Сетевые запросы идут к YouTube/его медиасерверам и, при обновлениях, официальным источникам компонентов.
Сайт, репозиторий и файлы релизов размещены на сервере проекта; при обращении к ним ведутся стандартные журналы запросов.
Полный список данных, разрешений и способ удаления — **[PRIVACY.md](PRIVACY.md)**.
## Обновления и удаление
**Обновление компонентов** скачивает совместимые обновления Python, yt-dlp и его библиотек,
EJS, Node.js и FFmpeg/FFprobe с официальных источников. Проверяются хеши, новый runtime проверяется
до переключения; при ошибке остаётся прежний комплект. Автоматическая проверка включена по умолчанию:
раз в сутки, пока помощник запущен, между операциями. Переключатель находится в меню помощника.
Python остаётся в ветке 3.13, Node.js — в 24 LTS.
Новую версию расширения установите из ZIP вручную. Новый код помощника — из отдельного EXE в Releases.
Автообновление компонентов не заменяет эти два обновления.
«Запустить» и «Проверить подключение» восстанавливают связь без перезагрузки страницы;
открытое меню также проверяет её раз в 10 секунд.
Удалить помощник можно в меню расширения или в приложениях Windows.
Скачанные файлы и история сохраняются; при желании историю можно удалить отдельно.
## Лицензии и разработка
Новые поставки собственного кода — по **[ограниченной пользовательской лицензии](LICENSE)**:
разрешены установка и использование по назначению. Изменение, самостоятельные форки,
производные версии и распространение без письменного разрешения правообладателя запрещены,
с учётом исключений лицензии. Это распространяется и на само расширение.
Публичный репозиторий Gitea доступен для просмотра.
Встроенные компоненты имеют собственные лицензии,
включая **GPL v3 or later** у поставляемой сборки FFmpeg. Состав, источники и материалы релиза —
**[THIRD_PARTY.md](THIRD_PARTY.md)**. Это независимый проект, не связанный с YouTube/Google, NVIDIA или AMD.
Сборка, тесты и публикация сайта описаны в **[CONTRIBUTING.md](CONTRIBUTING.md)**.
Используйте скачивание для контента, который вы вправе сохранять, с учётом правил источника.
+37
View File
@@ -0,0 +1,37 @@
# Сторонние компоненты
Лицензия в корне относится к собственным файлам YouTubeDL. Она разрешает использование
по назначению и ограничивает изменение, самостоятельные форки и распространение.
Встроенные компоненты помощника имеют **свои** лицензии; ограничения YouTubeDL
не заменяют и не ограничивают их, в том числе права по GPL и MIT.
Помощник вызывает FFmpeg отдельным процессом.
Автономный комплект помощника 1.3.2:
| Компонент | Версия | Проект / лицензия |
| --- | --- | --- |
| Python | 3.13.14, Windows embeddable x64 | [CPython](https://github.com/python/cpython/tree/v3.13.14), PSF и сопутствующие лицензии |
| Node.js | 24.21.0 x64 | [Node.js](https://github.com/nodejs/node/tree/v24.21.0), MIT и лицензии встроенных библиотек |
| yt-dlp | 2026.8.19 | [yt-dlp](https://github.com/yt-dlp/yt-dlp/tree/2026.08.19), Unlicense; зависимости лицензируются отдельно |
| yt-dlp-ejs | 0.8.0 | [EJS](https://github.com/yt-dlp/ejs), лицензия пакета в его dist-info |
| FFmpeg / FFprobe | N-127236-g17ec998942-20261007, shared win64 GPL | [FFmpeg](https://github.com/FFmpeg/FFmpeg/tree/17ec998942), GPL v3 or later для этой сборки; [сценарии FFmpeg-Builds](https://github.com/yt-dlp/FFmpeg-Builds) |
Python-пакеты комплекта: brotli 1.2.0, certifi 2026.7.22, charset-normalizer 3.5.2,
idna 3.20, mutagen 1.48.1, pycryptodomex 3.24.0, requests 2.34.2, urllib3 2.8.0,
websockets 17.2. Их `METADATA` и лицензии находятся рядом с библиотеками в
`runtime/python/Lib/site-packages/*.dist-info` установленного помощника.
Тексты Python/Node.js/FFmpeg также входят в установленный комплект.
В Releases приложены отдельный архив лицензий и исходных материалов,
манифест исходных бинарных архивов с SHA256 и контрольные суммы публикуемых файлов.
FFmpeg включает множество дополнительных библиотек с собственными лицензиями;
точная конфигурация и ссылки на исходные материалы приведены в этом архиве.
Для подготовки исходников зависимостей используются официальные сценарии
`download.sh` проекта FFmpeg-Builds; инструкции приведены в архиве и [CONTRIBUTING.md](CONTRIBUTING.md).
После обновления компонентов версии могут отличаться от этой таблицы.
Измеренные версии смотрите в **Составе комплекта** расширения и `bundle.json` установленного помощника.
Обновлённые wheels сохраняют лицензионные метаданные своих проектов.
YouTube, Windows, Chrome, Edge, NVIDIA и AMD — названия соответствующих правообладателей.
YouTubeDL — независимый проект; он не связан с YouTube/Google, NVIDIA или AMD.
+18
View File
@@ -0,0 +1,18 @@
# Графика проекта
Готовые файлы находятся в `site/assets` и используются как в README, так и на сайте проекта.
Растровые иллюстрации созданы встроенным **image_gen**; это декоративная графика, не скриншоты функций.
Программная генерация/редактирование этих растровых иллюстраций не использовалась.
- `hero.png`: исходная иллюстрация, 1774 × 887, без текста.
- `github-cover.png`: обложка с названием и русским слоганом, 1774 × 887. Создана редактированием hero через image_gen.
- `mark.svg`: векторная версия существующего знака скачивания; редактируется как SVG-код.
- `menu-dark.png`, `menu-light.png`: настоящий рендерер расширения, снятый в отдельном Edge через `node tools/capture-site-ui.mjs`. Использован `tests/ui-fixture.js`, тестовые метаданные публичного ролика и условная папка `C:\Videos`. Реальные аккаунты и cookies не использовались.
## Промпт hero
Use case: a GitHub repository cover and website hero background for a real Windows browser extension called YouTubeDL. Asset type: landscape editorial product illustration, approximately 2:1 aspect ratio. Create a refined dark charcoal composition with a cyan and pale turquoise luminous download arrow as the focal point, a few translucent rounded video frames and subtle audio waveform threads converging into one small file object. Evoke downloading video and joining video with sound, without fake software screenshots. Art direction: precise, restrained, premium 3D/vector hybrid, soft studio lighting, matte graphite surfaces, crisp edges, generous negative space on the left for HTML headings. Focal objects mainly on the right. Colors: graphite #11161d, cyan #39bdeb, ice #91ece0, tiny warm white highlights. Avoid clutter, grids, big gradients behind text, stock photography, logos of YouTube/NVIDIA/AMD, GPU images, branding claims, people, watermarks. Absolutely no letters, numbers, captions or text anywhere. This is an original decorative visual, not a feature screenshot. Opaque background.
## Промпт обложки
Use case: GitHub README cover for the browser extension YouTubeDL. Edit this supplied original artwork into a clean wide 2:1 brand cover. Keep the graphite background and cyan video/audio/download illustration on the right, with generous breathing room. On the empty left place exactly one large, impeccably legible modern white sans-serif word: "YouTubeDL". Beneath it place exactly the short Russian subtitle "Видео. Звук. Ваш выбор." in smaller soft cyan type. Align both texts left, around the vertical center, safe margins at least 8% of width. No other text, logos, GPU references, version numbers, badges, watermarks or UI screenshots. Refined editorial typography, balanced hierarchy. Preserve the original illustration aesthetic and subdued dark background; do not fill the left with extra objects.
+42
View File
@@ -0,0 +1,42 @@
# YouTubeDL 1.4.1
Исправлены формулировки в описании, инструкции, мастере и на сайте:
загрузчик использует сохранённые настройки; обновление компонентов получает
совместимые версии с официальных источников.
Инструкция установки обновлена в репозитории, ZIP и на сайте;
код расширения и помощника не менялся.
## Лицензия собственных материалов
Новые поставки сопровождаются ограниченной пользовательской лицензией,
которая явно распространяется на само расширение. Разрешены установка и
использование по назначению; изменение, самостоятельные форки, производные
версии и распространение без письменного разрешения запрещены с учётом исключений.
**Исключения:** сторонние компоненты сохраняют свои лицензии и права;
условия публичного GitHub предусматривают форки внутри платформы.
**[Лицензия](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/LICENSE)** ·
**[Сторонние компоненты](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/THIRD_PARTY.md)**
## Файлы и установка
- **YouTubeDL-1.4.1.zip** — расширение и актуальные пользовательские документы, включая лицензию.
- **YouTubeDL-Helper-Setup.exe** — отдельный помощник **1.3.0**, без изменений; все компоненты уже внутри.
- **YouTubeDL-Helper-manifest.json** — версии и SHA256 помощника.
- **YouTubeDL-ThirdParty-Materials.zip** — лицензии и исходные материалы, с актуальными пояснениями об области действия лицензии.
- **SHA256SUMS.txt** — контрольные суммы файлов этого выпуска.
**[Установка](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/INSTALL.md)** ·
**[Сайт](https://git.lonarpgproxy.space/extention/youtubedl/)** ·
**[Обработка данных](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/PRIVACY.md)**
Windows 10/11 x64, Edge/Chrome 120+. Распакуйте ZIP, выберите папку `extension`
в режиме разработчика браузера; скачайте и запустите помощник отдельно.
Для обновления расширения замените файлы в его постоянной папке, перезагрузите
расширение и обновите вкладки YouTube.
Проверены сборка без EXE внутри ZIP и наличие лицензии в архиве,
синтаксис и браузерный интерфейс, сайт в Edge на широком и узком экранах,
контрольные суммы файлов релиза.
+25
View File
@@ -0,0 +1,25 @@
# YouTubeDL 1.4.5
Расширение **1.4.5** и локальный помощник **1.3.2** для Windows 10/11 x64, Edge и Chrome 120+.
- Скачивание видео и Shorts с сохранёнными настройками; отдельная кнопка настроек рядом.
- Выбор качества, видеодорожек, языка звука, формата и кодека. Раздельный звук автоматически добавляется к видео.
- Рабочая папка и папка сохранения могут находиться на разных дисках.
- Уведомления показывают скачанные мегабайты, размер, скорость, этап обработки и прогресс FFmpeg. Отмена останавливает процессы соответствующей задачи.
- Перекодирование через NVIDIA NVENC или AMD AMF при поддержке оборудования; совместимые дорожки копируются без перекодирования. NVIDIA проверена на RTX 5080; физической AMD-карты для проверки не было.
- История открывается отдельной страницей с вкладками «Загрузки» и «Ошибки».
- Мастер первого запуска, проверка совместимости версий расширения и помощника, переподключение без обновления YouTube.
- Компоненты входят в помощник и обновляются с официальных источников.
- Исходники, релизы и сайт проекта размещены в Gitea. Кнопки установки ведут к новой странице релизов.
## Установка
1. Скачайте **YouTubeDL-1.4.5.zip**, распакуйте и загрузите папку `extension` через режим разработчика в `edge://extensions` или `chrome://extensions`.
2. Отдельно скачайте **YouTubeDL-Helper-Setup.exe** и запустите установщик. Все необходимые компоненты уже внутри; EXE не встроен в ZIP расширения.
3. Откройте YouTube, нажмите ⚙ и пройдите мастер настройки.
В релиз также включены манифест помощника, **YouTubeDL-ThirdParty-Materials.zip**, пользовательские документы и **SHA256SUMS.txt**.
[Сайт](https://git.lonarpgproxy.space/extention/youtubedl/) · [Инструкция](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/INSTALL.md) · [Данные](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/PRIVACY.md) · [Лицензия](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/LICENSE) · [Сторонние компоненты](https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/THIRD_PARTY.md)
Собственный код распространяется по ограниченной пользовательской лицензии: разрешено использование по назначению, изменения и распространение требуют письменного разрешения правообладателя. Сторонние компоненты сохраняют свои лицензии.
+359
View File
@@ -0,0 +1,359 @@
import { HOST_NAME, canonicalUrl, progressText, downloadSizeText, processingText, preferenceOptions, errorText, stabilizeDownloadSpeed, checkHelperCompatibility } from "./shared.js";
import { readSession, SESSION_PERMISSION } from "./session.js";
let nativePort = null;
const pending = new Map();
const clients = new Set();
const jobContexts = new Map();
let jobs = {};
let helper = { status: "idle", message: "Помощник ещё не подключён.", hardware: null };
let helperStartTask = null;
const notificationTimes = new Map();
const inspectionCache = new Map();
const ready = chrome.storage.local.get(["jobs"]).then(saved => {
jobs = saved.jobs || {};
for (const [id, job] of Object.entries(jobs)) {
jobContexts.set(id, { tabId: job.tabId, title: job.title });
if (job.status === "running") {
jobs[id] = { ...job, status: "error", stage: "error", error: "Браузер или помощник был закрыт. Запустите загрузку снова." };
}
}
});
function broadcast(message) {
for (const port of clients) {
try { port.postMessage(message); } catch { clients.delete(port); }
}
}
function helperState(status, message, hardware = null, compatibility = null) {
helper = { status, message, hardware, compatibility, checkedAt: Date.now() };
broadcast({ event: "helper", helper });
return helper;
}
function saveJobs() {
const recent = Object.values(jobs).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).slice(0, 30);
jobs = Object.fromEntries(recent.map(job => [job.id, job]));
chrome.storage.local.set({ jobs: Object.fromEntries(recent.filter(job => !job.transient).map(job => [job.id, job])) });
}
function disconnect(error, status = "error") {
inspectionCache.clear();
nativePort = null;
helperState(status, error);
for (const [id, request] of pending) {
clearTimeout(request.timeout);
request.reject(new Error(error));
pending.delete(id);
}
for (const job of Object.values(jobs)) {
if (job.status === "running") onJob({ ...job, status: "error", stage: "error", error });
}
}
function connectHost() {
if (nativePort) return nativePort;
helperState("checking", "Запускаю и проверяю локальный помощник…");
nativePort = chrome.runtime.connectNative(HOST_NAME);
const port = nativePort;
port.onMessage.addListener(message => {
if (message.event === "job") { onJob(message.job); return; }
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
clearTimeout(request.timeout);
message.ok ? request.resolve(message.result) : request.reject(new Error(errorText(message.error || "Ошибка помощника")));
});
port.onDisconnect.addListener(() => {
const detail = chrome.runtime.lastError?.message;
if (nativePort === port) {
const missing = /host.*not found|not.*registered|cannot find.*host/i.test(detail || "");
disconnect(missing ? "Помощник не установлен. Скачайте и запустите установщик, затем нажмите «Запустить»."
: detail ? `Не удалось подключиться к помощнику: ${detail}` : "Помощник отключён. Нажмите «Запустить».", missing ? "missing" : "offline");
}
});
return port;
}
function nativeRequest(action, payload = {}) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(action === "pick_folder" ? "Диалог выбора папки закрыт по тайм-ауту." : "Помощник отвечает слишком долго. Попробуйте снова."));
}, action === "ping" ? 4000 : action === "pick_folder" ? 600_000 : action === "inspect" ? 150_000 : 90_000);
pending.set(id, { resolve, reject, timeout });
let port;
try { port = connectHost(); port.postMessage({ id, action, payload }); }
catch (error) {
clearTimeout(timeout); pending.delete(id);
if (/disconnected port/i.test(error.message)) {
const failure = new Error("Связь с помощником прервана. Подключаюсь заново…");
failure.code = "NATIVE_PORT_DISCONNECTED";
if (nativePort === port) {
disconnect(failure.message, "offline");
try { port.disconnect(); } catch { /* Already disconnected. */ }
}
reject(failure);
} else reject(error);
}
});
}
function startHelper() {
if (!helperStartTask) helperStartTask = startHelperNow().finally(() => { helperStartTask = null; });
return helperStartTask;
}
async function startHelperNow() {
if (nativePort && helper.status !== "running" && !Object.values(jobs).some(job => job.status === "running")) {
const stale = nativePort; nativePort = null;
try { stale.disconnect(); } catch { /* Already disconnected. */ }
}
try {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const hardware = await nativeRequest("hello");
const ping = await nativeRequest("ping");
if (!ping.alive) throw new Error("Помощник не подтвердил связь.");
const compatibility = checkHelperCompatibility(hardware, chrome.runtime.getManifest().version);
if (!compatibility.compatible) {
helperState("incompatible", compatibility.message, hardware, compatibility);
throw new Error(compatibility.message);
}
helperState("running", "Помощник отвечает. Загрузка и обработка доступны.", hardware, compatibility);
return hardware;
} catch (error) {
if (attempt === 0 && error.code === "NATIVE_PORT_DISCONNECTED") continue;
throw error;
}
}
} catch (error) {
if (helper.status === "checking" || helper.status === "running") helperState("error", error.message);
throw error;
}
}
async function checkHelper() {
if (helperStartTask) {
try { await helperStartTask; } catch { /* The connection publishes its status. */ }
return helper;
}
if (nativePort && helper.status === "running") {
try {
const ping = await nativeRequest("ping");
if (!ping.alive) throw new Error("Помощник не подтвердил связь.");
helperState("running", "Помощник отвечает. Загрузка и обработка доступны.", helper.hardware, helper.compatibility);
} catch (error) { if (helper.status === "running") helperState("error", error.message); }
}
if (!nativePort || helper.status !== "running") {
try { await startHelper(); } catch { /* Missing/offline status is returned below. */ }
}
return helper;
}
async function notify(job) {
const previous = notificationTimes.get(job.id) || 0;
if (job.status === "running" && Date.now() - previous < 1500) return;
notificationTimes.set(job.id, Date.now());
const id = `ytdl-${job.id}`;
try {
if (job.status === "running") {
const options = { type: "progress", iconUrl: "icons/128.png", title: job.title || "YouTubeDL",
message: [progressText(job), downloadSizeText(job) || processingText(job)].filter(Boolean).join("\n"), progress: Number.isFinite(job.percent) ? Math.round(job.percent) : 0,
silent: true };
if (!await chrome.notifications.update(id, options)) await chrome.notifications.create(id, options);
} else {
await chrome.notifications.clear(id);
if (job.status !== "cancelled") await chrome.notifications.create(id, {
type: "basic", iconUrl: "icons/128.png", title: job.status === "complete" ? job.kind === "update" ? "Обновление компонентов" : job.kind === "dependency" ? "Установка завершена" : "Файл сохранён" : "Ошибка",
message: job.result?.filename || job.result?.title || job.error || "Готово", silent: job.status !== "error",
...(job.status === "complete" && job.result?.path ? { buttons: [{ title: "Показать файл" }] } : {}),
});
}
} catch { /* The on-page toast remains available if OS notifications are disabled. */ }
}
async function onJob(update) {
await ready;
if (update.error) update = { ...update, error: errorText(update.error) };
const context = jobContexts.get(update.id) || {};
const job = stabilizeDownloadSpeed({ ...jobs[update.id], ...update, speed: update.speed,
title: update.result?.title || context.title || update.title || jobs[update.id]?.title,
tabId: context.tabId ?? jobs[update.id]?.tabId }, jobs[update.id]);
jobs[job.id] = job;
broadcast({ event: "job", job });
if (Number.isInteger(job.tabId)) chrome.tabs.sendMessage(job.tabId, { type: "JOB_PROGRESS", job }).catch(() => {});
if (job.status !== "running" || !job.savedAt || Date.now() - job.savedAt > 1000) {
job.savedAt = Date.now(); saveJobs();
}
notify(job);
if (job.kind === "dependency" && job.status !== "running" && nativePort) startHelper().catch(() => {});
if (job.kind === "update" && job.status === "complete" && job.result?.restartRequired && nativePort) {
const previous = nativePort;
disconnect("Перезапускаю помощник с обновлёнными компонентами…", "checking");
previous.disconnect();
startHelper().catch(() => {});
}
chrome.action.setBadgeText({ text: Object.values(jobs).some(item => item.status === "running") ? "↓" : "" });
chrome.action.setBadgeBackgroundColor({ color: "#20a9de" });
}
async function handleUi(action, payload) {
await ready;
if (action === "session_status") return readSession(Number.isInteger(payload.tabId) ? payload.tabId : null);
if (action === "session_enable") {
if (payload.enabled && !await chrome.permissions.contains(SESSION_PERMISSION)) throw new Error("Разрешите использовать вход в YouTube в окне браузера.");
await chrome.storage.local.set({ youtubeSession: payload.enabled === true });
inspectionCache.clear();
if (!payload.enabled) await chrome.permissions.remove(SESSION_PERMISSION);
return readSession(Number.isInteger(payload.tabId) ? payload.tabId : null);
}
if (action === "state") return { jobs: Object.values(jobs), helper };
if (action === "open_history") {
const theme = ["light", "dark"].includes(payload.theme) ? payload.theme : "auto";
await chrome.tabs.create({ url: chrome.runtime.getURL(`history.html?theme=${theme}`) });
return { opened: true };
}
if (action === "helper_status") return checkHelper();
if (action === "hello" || action === "helper_start") return startHelper();
if (action === "update_settings") {
const settings = await nativeRequest(action, { enabled: payload.enabled });
if (helper.hardware) helper.hardware.updateSettings = settings;
helperState(helper.status, helper.message, helper.hardware, helper.compatibility);
return settings;
}
if (action === "helper_remove") {
if (!nativePort || helper.status !== "running") throw new Error("Сначала подключите помощник.");
if (Object.values(jobs).some(job => job.status === "running")) throw new Error("Дождитесь завершения загрузок перед удалением помощника.");
const result = await nativeRequest("helper_uninstall");
const old = nativePort;
disconnect("Помощник удалён. Для установки скачайте и запустите установщик.", "missing");
old?.disconnect();
return result;
}
if (!["hello", "inspect", "pick_folder", "download", "dependency_install", "helper_update", "cancel", "reveal", "jobs"].includes(action)) throw new Error("Неизвестная операция");
if (["inspect", "download"].includes(action) && !canonicalUrl(payload.url)) throw new Error("Нужна ссылка на видео YouTube");
if (["inspect", "download", "dependency_install", "helper_update"].includes(action) && (!nativePort || helper.status !== "running")) await startHelper();
if (["inspect", "download"].includes(action)) payload = await mediaPayload(payload);
if (action === "inspect") return inspectVideo(payload);
if (action === "download" && payload.workFolder) {
if (!nativePort || helper.status !== "running") await startHelper();
if (helper.hardware?.supportsWorkFolder !== true) throw new Error("Для отдельной рабочей папки установите помощник 1.3.1 или новее.");
}
if (["download", "dependency_install", "helper_update"].includes(action)) {
const title = String(payload.title || (action === "helper_update" ? "Обновление компонентов" : "Видео")).slice(0, 300);
const tabId = Number.isInteger(payload.tabId) ? payload.tabId : null;
const result = await nativeRequest(action, payload);
jobContexts.set(result.jobId, { title, tabId });
// Worker events may arrive immediately after the response, before this microtask.
onJob({ ...jobs[result.jobId], id: result.jobId, title, tabId, url: payload.url,
status: jobs[result.jobId]?.status || "running", stage: jobs[result.jobId]?.stage || "preparing",
kind: action === "helper_update" ? "update" : action === "dependency_install" ? "dependency" : "download",
...(action === "helper_update" ? { canCancel: false } : {}),
createdAt: jobs[result.jobId]?.createdAt || Date.now() / 1000 });
return result;
}
return nativeRequest(action, payload);
}
async function mediaPayload(payload) {
// Ignore cookies supplied by callers; only the browser API is a session source.
const { browserCookies: ignoredCookies, ...options } = payload;
const session = await readSession(Number.isInteger(payload.tabId) ? payload.tabId : null, true);
if (session.status === "connected" && !helper.hardware?.supportsBrowserCookies) {
throw new Error("Обновите помощник для подключения входа: нажмите «Установить», запустите скачанный установщик и обновите расширение.");
}
return { ...options, ...(session.status === "connected" ? { browserCookies: session.cookies } : {}) };
}
async function inspectVideo(payload) {
// Only the summary is cached; URLs for downloading are refreshed by the worker.
const fingerprint = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(payload.browserCookies || [])));
const key = `${canonicalUrl(payload.url)}|${payload.tabId || ""}|${[...new Uint8Array(fingerprint)].map(byte => byte.toString(16).padStart(2, "0")).join("")}`;
const cached = inspectionCache.get(key);
if (cached && cached.expires > Date.now() && !payload.refresh) return cached.task;
const entry = { expires: Date.now() + 45000, task: nativeRequest("inspect", payload) };
inspectionCache.delete(key); inspectionCache.set(key, entry);
if (inspectionCache.size > 20) inspectionCache.delete(inspectionCache.keys().next().value);
try { return await entry.task; }
catch (error) { if (inspectionCache.get(key) === entry) inspectionCache.delete(key); throw error; }
}
async function quickDownload(url, tabId) {
url = canonicalUrl(url);
if (!url) throw new Error("Нужна ссылка на видео YouTube.");
const { preferences = {} } = await chrome.storage.local.get("preferences");
if (!preferences.folder) return { needSetup: true, error: "Сначала выберите папку и сохраните настройки скачивания." };
if (Object.values(jobs).filter(job => job.status === "running").length >= 2) {
return { needSetup: true, error: "Уже выполняются две операции. Дождитесь завершения одной." };
}
const temporaryId = `preparing-${crypto.randomUUID()}`;
jobContexts.set(temporaryId, { tabId, title: "Подготовка скачивания" });
await onJob({ id: temporaryId, url, status: "running", stage: "preparing", percent: null, canCancel: false, transient: true, createdAt: Date.now() / 1000 });
try {
await startHelper();
if (helper.hardware?.ready === false) throw new Error("Установите недостающие компоненты через настройки скачивания.");
const metadata = await handleUi("inspect", { url, tabId });
const result = await handleUi("download", { ...preferenceOptions(metadata, preferences), url, title: metadata.title, tabId });
delete jobs[temporaryId]; jobContexts.delete(temporaryId);
broadcast({ event: "remove_job", jobId: temporaryId });
chrome.tabs.sendMessage(tabId, { type: "REMOVE_JOB", jobId: temporaryId }).catch(() => {});
chrome.notifications.clear(`ytdl-${temporaryId}`).catch(() => {});
saveJobs();
return { ok: true, jobId: result.jobId };
} catch (error) {
await onJob({ id: temporaryId, status: "error", stage: "error", error: error.message, transient: false });
return { ok: false, needSetup: helper.status !== "running" || /папк|компонент|вход|авторизац|бот|помощник|проверку/i.test(error.message), error: error.message };
}
}
chrome.runtime.onConnect.addListener(port => {
const pageUrl = port.sender?.url?.split(/[?#]/)[0];
if (port.name !== "ytdl-ui" || port.sender?.id !== chrome.runtime.id ||
!["downloader.html", "history.html"].some(page => pageUrl === chrome.runtime.getURL(page))) return;
clients.add(port);
ready.then(() => port.postMessage({ event: "state", jobs: Object.values(jobs), helper })).catch(() => {});
port.onDisconnect.addListener(() => clients.delete(port));
port.onMessage.addListener(async message => {
try { port.postMessage({ id: message.id, ok: true, result: await handleUi(message.action, message.payload || {}) }); }
catch (error) {
// Save failures before a worker was created; worker failures arrive through onJob.
const titles = { inspect: "Получение качества видео", download: "Подготовка скачивания", dependency_install: "Установка компонента", helper_update: "Обновление компонентов" };
if (titles[message.action]) {
const payload = message.payload || {};
await onJob({ id: `error-${crypto.randomUUID()}`, title: String(payload.title || titles[message.action]).slice(0, 300),
url: canonicalUrl(payload.url), status: "error", stage: "error", error: error.message,
kind: message.action === "inspect" ? "inspection" : message.action === "helper_update" ? "update" : message.action === "dependency_install" ? "dependency" : "download",
createdAt: Date.now() / 1000 }).catch(() => {});
}
try { port.postMessage({ id: message.id, ok: false, error: error.message }); } catch {}
}
});
});
async function openWindow(url, tabId, theme = "auto") {
const query = new URLSearchParams({ url: canonicalUrl(url) || "", tabId: String(tabId || ""), theme });
await chrome.windows.create({ url: chrome.runtime.getURL(`downloader.html?${query}`), type: "popup", width: 520, height: 820 });
}
chrome.action.onClicked.addListener(tab => openWindow(tab.url, tab.id));
chrome.runtime.onMessage.addListener((message, sender, respond) => {
if (message.type === "CLOSE_MENU" && sender.id === chrome.runtime.id && sender.tab &&
sender.url?.startsWith(chrome.runtime.getURL("downloader.html"))) {
chrome.tabs.sendMessage(sender.tab.id, { type: "CLOSE_MENU" })
.then(() => respond({ ok: true }), () => respond({ ok: true }));
return true;
}
let isYouTube = false;
try { const origin = new URL(sender.url); isYouTube = origin.protocol === "https:" && ["www.youtube.com", "m.youtube.com"].includes(origin.hostname); } catch {}
if (sender.id !== chrome.runtime.id || !sender.tab || !isYouTube) return false;
const tabId = sender.tab.id;
if (message.type === "QUICK_DOWNLOAD") {
quickDownload(message.url, tabId).then(respond, error => respond({ needSetup: true, error: error.message }));
return true;
}
if (message.type === "GET_CONTEXT") { respond({ tabId, jobs: Object.values(jobs).filter(job => job.tabId === tabId) }); return false; }
if (message.type === "OPEN_WINDOW") {
openWindow(sender.url, tabId).then(() => respond({ ok: true }), error => respond({ ok: false, error: error.message })); return true;
}
if (["CANCEL_JOB", "REVEAL_JOB"].includes(message.type) && jobs[message.jobId]?.tabId === tabId) {
nativeRequest(message.type === "CANCEL_JOB" ? "cancel" : "reveal", { jobId: message.jobId })
.then(result => respond({ ok: true, result }), error => respond({ ok: false, error: error.message }));
return true;
}
return false;
});
chrome.notifications.onButtonClicked.addListener((id, index) => {
if (index === 0 && id.startsWith("ytdl-")) nativeRequest("reveal", { jobId: id.slice(5) }).catch(() => {});
});
+100
View File
@@ -0,0 +1,100 @@
(() => {
let toastHost, toastRoot, tabId = null;
const dismissed = new Set();
const toastTimers = new Map();
const knownJobs = new Map();
const stages = { preparing: "Подготовка", downloading: "Скачивание", merging: "Объединение дорожек", remuxing: "Подготовка файла", finalizing: "Финализация файла", encoding: "Перекодирование", audio: "Обработка аудио", saving: "Сохранение файла", installing: "Установка", updating: "Обновление компонентов", complete: "Файл сохранён", error: "Ошибка", cancelled: "Отменено", cancelling: "Останавливаю обработку" };
const isDark = () => document.documentElement.hasAttribute("dark") || document.documentElement.dataset.theme === "dark";
function updateText(node, value) {
if (node.textContent === value) return;
if (node.childNodes.length === 1 && node.firstChild.nodeType === 3) node.firstChild.data = value;
else node.textContent = value;
}
function downloadSizeText(job) {
const megabytes = value => `${(value / 1048576).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} МБ`;
if (job.status === "complete" && Number.isFinite(job.result?.size) && job.result.size >= 0) return `Размер файла: ${megabytes(job.result.size)}`;
const value = job.stage === "saving" ? job.copied : job.downloaded;
if (!["downloading", "saving"].includes(job.stage) || !Number.isFinite(value) || value < 0) return "";
const downloaded = `${job.stage === "saving" ? "Сохранено" : "Скачано"} ${megabytes(value)}`;
return Number.isFinite(job.total) && job.total > 0 ? `${downloaded} из ${megabytes(job.total)}` : downloaded;
}
function processingText(job) {
if (!["merging", "remuxing", "finalizing", "encoding", "audio"].includes(job.stage)) return "";
const duration = value => {
const seconds = Math.floor(value);
return seconds >= 3600 ? `${Math.floor(seconds / 3600)}:${String(Math.floor(seconds / 60) % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}` : `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
};
const parts = [];
if (Number.isFinite(job.processedSeconds) && job.processedSeconds >= 0) parts.push(`Обработано ${duration(job.processedSeconds)}${job.durationSeconds > 0 ? ` из ${duration(job.durationSeconds)}` : ""}`);
if (Number.isFinite(job.written) && job.written >= 0) parts.push(`Записано ${(job.written / 1048576).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} МБ`);
return parts.join(" · ");
}
const commonStyle = ` :host{font-family:Roboto,"Segoe UI",sans-serif;color-scheme:light;--bg:#fff;--fg:#152334;--muted:#6a7787;--line:#e1e7ee;--track:#edf2f7;--accent:#0788bc}:host([dark]){color-scheme:dark;--bg:#1b2029;--fg:#eef2fa;--muted:#a1adbd;--line:#333e4e;--track:#2b3543;--accent:#59d0f4}*{box-sizing:border-box}button{font:inherit;cursor:pointer}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}`;
function applyTheme() { toastHost?.toggleAttribute("dark", isDark()); }
function ensureToasts() {
if (toastHost?.isConnected) return;
toastHost = document.createElement("div"); toastHost.id = "ytdl-notifications";
toastHost.style.cssText = "position:fixed;right:20px;bottom:20px;width:min(365px,calc(100vw - 32px));z-index:2147483647;pointer-events:none";
toastRoot = toastHost.attachShadow({ mode: "open" });
toastRoot.innerHTML = `<style>${commonStyle}.transfer{font-size:11px;color:var(--muted);margin-top:8px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.toast{background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:16px;padding:15px;margin-top:9px;box-shadow:0 12px 38px #0003;pointer-events:auto;animation:toast-in .32s cubic-bezier(.2,.8,.2,1) both;position:relative}.top{display:flex;gap:11px;align-items:center}.icon{width:35px;height:35px;display:grid;place-items:center;flex-shrink:0;border-radius:11px;background:color-mix(in srgb,var(--accent) 13%,var(--bg));color:var(--accent);font-size:24px}.copy{min-width:0;flex:1}.title{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.stage{font-size:11px;color:var(--muted);margin-top:4px}.dismiss{border:0;background:transparent;color:var(--muted);font-size:20px;align-self:flex-start;line-height:16px;padding:0 0 0 3px}.track{height:5px;background:var(--track);border-radius:5px;margin-top:13px;overflow:hidden}.fill{height:100%;border-radius:5px;background:linear-gradient(90deg,#149bce,#59ded5);transition:width .3s ease;position:relative;overflow:hidden}.toast[data-status=running] .fill:not(.indeterminate):after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,#fff6,transparent);animation:shine 1.6s linear infinite}.fill.indeterminate{width:35%!important;transition:none;animation:slide 1.7s infinite ease-in-out}.bottom{display:flex;justify-content:space-between;align-items:center;margin-top:9px;font-size:10px;color:var(--muted)}.action{border:0;background:none;color:var(--accent);font-size:11px;padding:0}.complete .icon{background:#35c99820;color:#22b98a}.error .icon{background:#ff656520;color:#e67171}.toast.exit{animation:toast-out .18s ease-in forwards}.encoding .icon{animation:pulse 1.8s infinite}.label{font-variant-numeric:tabular-nums;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:235px}@keyframes toast-in{from{opacity:0;transform:translateY(18px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes toast-out{to{opacity:0;transform:translateX(30px)}}@keyframes shine{from{transform:translateX(-100%)}to{transform:translateX(100%)}}@keyframes slide{0%,100%{transform:translateX(0)}50%{transform:translateX(calc(100% / .35 - 100%))}}@keyframes pulse{50%{box-shadow:0 0 0 5px #31bee912}}@media(max-width:450px){.toast{padding:12px}} </style>`;
document.documentElement.append(toastHost); applyTheme();
}
function dismiss(id) {
dismissed.add(id); clearTimeout(toastTimers.get(id));
const toast = toastRoot?.querySelector(`[data-id="${CSS.escape(id)}"]`);
toast?.classList.add("exit"); setTimeout(() => toast?.remove(), 180);
}
function showJob(job) {
knownJobs.set(job.id, job);
if (dismissed.has(job.id)) return;
ensureToasts();
let toast = toastRoot.querySelector(`[data-id="${CSS.escape(job.id)}"]`);
if (!toast) {
toast = document.createElement("article"); toast.className = "toast"; toast.dataset.id = job.id;
toast.setAttribute("role", "status"); toast.setAttribute("aria-live", "polite");
toast.innerHTML = '<div class="top"><div class="icon">↓</div><div class="copy"><div class="title"></div><div class="stage"></div></div><button class="dismiss" aria-label="Скрыть уведомление">×</button></div><div class="track"><div class="fill"></div></div><div class="transfer" hidden></div><div class="bottom"><span class="label"></span><button class="action"></button></div>';
toast.querySelector(".dismiss").addEventListener("click", () => dismiss(job.id));
toast.querySelector(".action").addEventListener("click", () => {
const current = knownJobs.get(job.id);
chrome.runtime.sendMessage({ type: current.status === "complete" ? "REVEAL_JOB" : "CANCEL_JOB", jobId: job.id }).catch(() => {});
});
toastRoot.append(toast);
}
const className = `toast ${job.stage}`;
if (toast.className !== className) toast.className = className;
if (toast.dataset.status !== job.status) toast.dataset.status = job.status;
updateText(toast.querySelector(".title"), job.title || "YouTubeDL");
const suffix = job.stage === "downloading" ? ` · ${job.track === "audio" ? "аудио" : "видео"}` : ["encoding", "audio"].includes(job.stage) && job.engine ? ` · ${{ nvenc: "NVIDIA NVENC", amf: "AMD AMF", cpu: "CPU", copy: "без перекодирования" }[job.engine] || job.engine}` : ["merging", "remuxing"].includes(job.stage) && !job.message ? " · без перекодирования" : "";
updateText(toast.querySelector(".stage"), (job.stage === "complete" && job.kind === "update" ? job.result?.title || "Обновление завершено" : ["updating", "merging", "remuxing", "finalizing", "encoding", "audio"].includes(job.stage) && job.message ? job.message : job.stage === "complete" && job.kind === "dependency" ? "Установка завершена" : stages[job.stage] || "Обработка") + suffix);
updateText(toast.querySelector(".icon"), { complete: "✓", error: "!", cancelled: "×", encoding: "✦", merging: "⇄", remuxing: "⇄", audio: "♫" }[job.stage] || "↓");
const fill = toast.querySelector(".fill");
const width = `${Number.isFinite(job.percent) ? Math.max(0, Math.min(100, job.percent)) : 0}%`;
if (fill.style.width !== width) fill.style.width = width;
fill.classList.toggle("indeterminate", job.status === "running" && !Number.isFinite(job.percent));
toast.querySelector(".track").hidden = ["error", "cancelled"].includes(job.status);
const transfer = toast.querySelector(".transfer");
updateText(transfer, downloadSizeText(job) || processingText(job)); transfer.hidden = !transfer.textContent;
let label = Number.isFinite(job.percent) ? `${Math.round(job.percent)}%` : "";
if (job.stage === "downloading" && Number.isFinite(job.speed) && job.speed >= 0) label += `${label ? " · " : ""}${(job.speed / 1048576).toFixed(1)} МБ/с`;
if (["merging", "remuxing", "encoding", "audio"].includes(job.stage) && job.speed) label += `${label ? " · " : ""}${job.speed}`;
if (["merging", "remuxing", "encoding", "audio"].includes(job.stage) && Number.isFinite(job.eta) && job.eta > 0) label += `${label ? " · " : ""}осталось ≈ ${Math.ceil(job.eta)} с`;
if (job.status === "complete") label = job.result?.filename || job.result?.title || "Готово";
if (job.status === "error") label = job.error || "Ошибка";
const readout = toast.querySelector(".label");
updateText(readout, label);
const tooltip = job.error || job.result?.path || label;
if (readout.title !== tooltip) readout.title = tooltip;
const action = toast.querySelector(".action");
updateText(action, job.status === "complete" ? "Открыть папку ↗" : "Отменить");
action.hidden = !["running", "complete"].includes(job.status) || job.canCancel === false || (["dependency", "update"].includes(job.kind) && job.status === "complete");
if (job.status !== "running" && !toastTimers.has(job.id)) toastTimers.set(job.id, setTimeout(() => dismiss(job.id), job.status === "error" ? 60000 : 20000));
}
chrome.runtime.onMessage.addListener(message => {
if (message.type === "JOB_PROGRESS") showJob(message.job);
if (message.type === "REMOVE_JOB") dismiss(message.jobId);
});
new MutationObserver(applyTheme).observe(document.documentElement, { attributes: true, attributeFilter: ["dark", "data-theme"] });
chrome.runtime.sendMessage({ type: "GET_CONTEXT" }).then(context => {
tabId = context?.tabId; for (const job of context?.jobs || []) if (job.status === "running") showJob(job);
}).catch(() => {});
})();
+261
View File
@@ -0,0 +1,261 @@
(() => {
const controls = new Set();
const previews = new WeakMap();
const previewLayouts = new Set();
let layoutScheduled = false;
const previewResize = new ResizeObserver(schedulePreviewLayout);
let watchButton = null, shortsButton = null, shortsRenderer = null;
let menu = null, menuAnchor = null, menuSize = { width: 270, height: 142 };
const icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/></svg>';
const gear = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 3-.6 2.4-2.1 1.2L4 6l-2 3.5 1.8 1.7v2.4L2 15.3 4 19l2.3-.6 2.1 1.2L9 22h4l.6-2.4 2.1-1.2 2.3.6 2-3.7-1.8-1.7v-2.4L20 9.5 18 6l-2.3.6-2.1-1.2L13 3Z" transform="translate(1 -.5) scale(.95)"/><circle cx="12" cy="12" r="3"/></svg>';
const themeStyle = ':host{font-family:Roboto,"Segoe UI",sans-serif;--bg:#fff;--fg:#0f0f0f;--muted:#606060;--button:#0000000d;--hover:#0000001a;--pressed:#00000026;--line:#0000001a}:host([dark]){--bg:#212121;--fg:#f1f1f1;--muted:#aaa;--button:#ffffff1a;--hover:#ffffff33;--pressed:#ffffff40;--line:#ffffff1a}*{box-sizing:border-box}button{font:inherit;cursor:pointer}';
const dark = () => document.documentElement.hasAttribute('dark') || document.documentElement.dataset.theme === 'dark';
function canonical(value) {
try {
const parsed = new URL(value, location.href);
if (parsed.protocol !== 'https:' || !['www.youtube.com', 'm.youtube.com', 'youtube.com', 'youtu.be'].includes(parsed.hostname)) return null;
const match = parsed.pathname.match(/^\/(?:shorts|live)\/([\w-]{11})(?:\/|$)/);
const id = parsed.hostname === 'youtu.be' ? parsed.pathname.slice(1) : parsed.pathname === '/watch' ? parsed.searchParams.get('v') : match?.[1];
return /^[\w-]{11}$/.test(id || '') ? `https://www.youtube.com/watch?v=${id}` : null;
} catch { return null; }
}
function theme() {
for (const control of controls) {
if (!control.isConnected) { controls.delete(control); continue; }
control.toggleAttribute('dark', control.dataset.kind === 'thumbnail' || dark());
}
menu?.toggleAttribute('dark', dark());
menu?.querySelector('iframe')?.contentWindow?.postMessage({ type: 'YTDL_THEME', theme: dark() ? 'dark' : 'light' }, chrome.runtime.getURL('').slice(0, -1));
}
function closeMenu() {
if (!menu) return;
const closing = menu; menu = null; menuAnchor = null;
closing.id = ''; closing.style.animation = 'none'; closing.classList.add('ytdl-closing');
setTimeout(() => closing.remove(), 150);
}
function positionMenu() {
if (!menu || !menuAnchor) return;
if (!menuAnchor.isConnected) { closeMenu(); return; }
const rect = menuAnchor.getBoundingClientRect();
const width = Math.min(menuSize.width, innerWidth - 24);
const height = Math.min(menuSize.height, innerHeight - 32);
const left = Math.min(Math.max(12, rect.left), innerWidth - width - 12);
const top = rect.bottom + height + 16 < innerHeight ? rect.bottom + 8
: Math.max(12, Math.min(rect.top - height - 8, innerHeight - height - 16));
Object.assign(menu.style, { width: `${width}px`, height: `${height}px`, left: `${left}px`, top: `${top}px` });
}
function newMenu(trigger, size) {
closeMenu(); menuAnchor = trigger; menuSize = size;
menu = document.createElement('div'); menu.id = 'ytdl-menu';
menu.style.cssText = 'position:fixed;z-index:2147483646;border-radius:14px;overflow:hidden;box-shadow:0 12px 40px #0005,0 0 0 1px #80808033;animation:ytdl-menu-in .16s ease-out both';
document.documentElement.append(menu); theme(); positionMenu();
return menu;
}
async function openSetup(trigger, url, notice = '') {
let context;
try { context = await chrome.runtime.sendMessage({ type: 'GET_CONTEXT' }); } catch { return; }
if (!trigger.isConnected) return;
const panel = newMenu(trigger, { width: 460, height: 780 });
const frame = document.createElement('iframe');
const params = new URLSearchParams({ url, tabId: String(context?.tabId || ''), embedded: '1', theme: dark() ? 'dark' : 'light', notice });
frame.src = chrome.runtime.getURL(`downloader.html?${params}`);
frame.title = 'Настроить скачивание'; frame.style.cssText = 'border:0;width:100%;height:100%;display:block';
panel.append(frame);
}
async function download(trigger, url) {
if (!url) return;
closeMenu();
try {
const response = await chrome.runtime.sendMessage({ type: 'QUICK_DOWNLOAD', url });
if (response?.needSetup) openSetup(trigger, url, response.error || 'Выберите папку и сохраните настройки.');
} catch (error) { openSetup(trigger, url, error.message); }
}
function makeControl(kind, resolveUrl) {
const host = document.createElement('span'); host.dataset.ytdlControl = 'true'; host.dataset.kind = kind;
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>${themeStyle}.pair{display:flex;align-items:center;gap:6px}button{display:flex;align-items:center;justify-content:center;gap:6px;height:36px;padding:0 16px;border:0;border-radius:999px;background:var(--button);color:var(--fg);font-size:14px;font-weight:500;white-space:nowrap}button:hover{background:var(--hover)}button:active{background:var(--pressed)}button:focus-visible{outline:2px solid var(--fg);outline-offset:2px}.glyph{display:grid;place-items:center}svg{width:20px;height:20px}.settings{width:36px;padding:0}:host([data-kind=thumbnail]) button{width:34px;height:34px;padding:0;background:#0f0f0fbb;color:#fff;box-shadow:0 1px 5px #0003}:host([data-kind=thumbnail]) button:hover{background:#303030}:host([data-kind=thumbnail]) .label{display:none}:host([data-kind=shorts]) .pair{flex-direction:column;gap:9px}:host([data-kind=shorts]) .download{background:transparent;height:auto;padding:0;flex-direction:column;gap:6px;color:var(--fg)}:host([data-kind=shorts]) .glyph{width:48px;height:48px;border-radius:50%;background:var(--button)}:host([data-kind=shorts]) .download:hover .glyph{background:var(--hover)}:host([data-kind=shorts]) .download:active .glyph{background:var(--pressed)}:host([data-kind=shorts]) svg{width:24px;height:24px}:host([data-kind=shorts]) .settings svg{width:19px;height:19px}:host([data-kind=shorts]) .label{font-size:12px}</style><div class="pair"><button class="download" type="button" title="Скачать по сохранённым настройкам" aria-label="Скачать по сохранённым настройкам"><span class="glyph">${icon}</span><span class="label">Скачать</span></button><button class="settings" type="button" title="Настройки скачивания" aria-label="Настройки скачивания">${gear}</button></div>`;
for (const button of shadow.querySelectorAll('button')) {
button.addEventListener('pointerdown', event => { event.stopPropagation(); });
button.addEventListener('keydown', event => { event.stopPropagation(); });
button.addEventListener('click', event => {
event.preventDefault(); event.stopPropagation();
const url = resolveUrl(); if (!url) return;
if (button.classList.contains('download')) download(host, url);
else if (menuAnchor === host) closeMenu(); else openSetup(host, url);
});
}
controls.add(host); return host;
}
function attachWatch() {
if (!canonical(location.href) || location.pathname.startsWith('/shorts/')) { watchButton?.remove(); watchButton = null; return; }
if (watchButton?.isConnected) return;
const subscribe = document.querySelector('ytd-watch-metadata #subscribe-button, ytd-video-owner-renderer #subscribe-button, ytm-slim-video-metadata-renderer ytm-subscribe-button-renderer');
if (!subscribe) return;
watchButton = makeControl('watch', () => canonical(location.href)); watchButton.id = 'ytdl-subscribe-download';
watchButton.style.cssText = 'display:inline-flex;align-items:center;flex-shrink:0;margin-inline-start:8px;vertical-align:middle';
subscribe.after(watchButton);
}
function styleValue(node, key, value) { if (node.style[key] !== value) node.style[key] = value; }
function usableRect(element) {
if (!element?.isConnected || getComputedStyle(element).visibility === 'hidden') return null;
const rect = element.getBoundingClientRect();
return rect.width >= 90 && rect.height >= 50 ? rect : null;
}
function picture(link, root) {
// A title link is a video URL too, but must never become a positioning anchor.
const media = [...link.querySelectorAll('img, video')].find(element => usableRect(element));
const frame = link.closest('ytd-thumbnail, yt-thumbnail-view-model') || link.querySelector('yt-thumbnail-view-model, yt-image');
const element = media || (usableRect(frame) ? frame : null);
if (!element) return null;
let rect = usableRect(element);
const crop = usableRect(frame);
if (crop && rect) {
const left = Math.max(rect.left, crop.left), top = Math.max(rect.top, crop.top);
const right = Math.min(rect.right, crop.right), bottom = Math.min(rect.bottom, crop.bottom);
rect = { left, top, right, bottom, width: right - left, height: bottom - top };
}
return rect?.width >= 90 && rect.height >= 50 && root.contains(element) ? { element, rect } : null;
}
function observePicture(preview, element) {
if (preview.element === element) return;
if (preview.element && preview.element !== preview.root) previewResize.unobserve(preview.element);
preview.element = element; previewResize.observe(element);
}
function visibleOverlay(root, left, top) {
let clip = { left: 0, top: 0, right: innerWidth, bottom: innerHeight };
// YouTube's absolutely positioned ytd-app leaves BODY with height 0.
// BODY/HTML overflow applies to the viewport, already handled above.
for (let element = root; element && element !== document.body && element !== document.documentElement; element = element.parentElement) {
const css = getComputedStyle(element);
if (css.display === 'contents' || css.display === 'inline') continue;
const x = /hidden|clip|auto|scroll/.test(css.overflowX), y = /hidden|clip|auto|scroll/.test(css.overflowY);
if (!x && !y) continue;
const rect = element.getBoundingClientRect();
if (x) { clip.left = Math.max(clip.left, rect.left); clip.right = Math.min(clip.right, rect.right); }
if (y) { clip.top = Math.max(clip.top, rect.top); clip.bottom = Math.min(clip.bottom, rect.bottom); }
}
return left >= clip.left && left + 74 <= clip.right && top >= clip.top && top + 34 <= clip.bottom;
}
function positionPreview(preview) {
const { root, host, floating } = preview;
if (!root.isConnected) {
previewResize.unobserve(root);
if (preview.element) previewResize.unobserve(preview.element);
previewLayouts.delete(preview); previews.delete(root); controls.delete(host); host.remove(); return;
}
let measured = preview.link?.isConnected && picture(preview.link, root);
if (!measured && floating) {
const link = [...root.querySelectorAll('a[href]')].find(candidate => canonical(candidate.href) && picture(candidate, root));
if (link) { preview.link = link; measured = picture(link, root); }
}
const base = root.getBoundingClientRect();
if (measured) {
observePicture(preview, measured.element);
const scale = root.offsetWidth ? base.width / root.offsetWidth : 1;
preview.box = { left: (measured.rect.left - base.left) / scale, top: (measured.rect.top - base.top) / scale,
width: measured.rect.width / scale, height: measured.rect.height / scale, rootWidth: root.offsetWidth };
}
// A Shorts hover player may remove or hide the original picture. Its buttons
// live outside that subtree and retain the last measured image rectangle.
let rect = measured?.rect;
if (!rect && floating && preview.box && base.width > 0) {
const factor = base.width / preview.box.rootWidth;
rect = { left: base.left + preview.box.left * factor, top: base.top + preview.box.top * factor,
width: preview.box.width * factor, height: preview.box.height * factor };
rect.right = rect.left + rect.width; rect.bottom = rect.top + rect.height;
}
if (!rect || rect.width < 90 || rect.height < 50) { styleValue(host, 'display', 'none'); return; }
if (floating) {
const top = rect.bottom - 42, left = rect.left + 8;
const visible = visibleOverlay(root, left, top);
styleValue(host, 'display', visible ? 'inline-flex' : 'none');
styleValue(host, 'left', `${left}px`); styleValue(host, 'top', `${top}px`);
} else {
styleValue(host, 'display', 'inline-flex');
const parent = host.offsetParent;
if (!parent) return;
const origin = parent.getBoundingClientRect();
const sx = parent.offsetWidth ? origin.width / parent.offsetWidth : 1;
const sy = parent.offsetHeight ? origin.height / parent.offsetHeight : sx;
styleValue(host, 'left', `${(rect.left - origin.left) / sx - parent.clientLeft + parent.scrollLeft + 8}px`);
styleValue(host, 'top', `${(rect.bottom - origin.top) / sy - parent.clientTop + parent.scrollTop - 42}px`);
}
}
function schedulePreviewLayout() {
if (layoutScheduled) return;
layoutScheduled = true;
requestAnimationFrame(() => { layoutScheduled = false; for (const preview of previewLayouts) positionPreview(preview); positionMenu(); });
}
let previewSequence = 0;
function attachThumbnails() {
const links = document.querySelectorAll('a#thumbnail[href], ytd-thumbnail a[href], yt-thumbnail-view-model a[href], a.yt-lockup-view-model__content-image[href], a.ytLockupViewModelContentImage[href], yt-shorts-lockup-view-model a[href], ytm-shorts-lockup-view-model a[href], ytd-reel-item-renderer a[href]');
for (const link of links) {
const url = canonical(link.href);
if (!url) continue;
const card = link.closest('ytm-shorts-lockup-view-model, yt-shorts-lockup-view-model, ytd-reel-item-renderer');
const root = card || link.closest('ytd-thumbnail, yt-thumbnail-view-model') || link.querySelector('yt-thumbnail-view-model') || link;
const measured = picture(link, root);
// Ignore metadata links even when a multiline title has thumbnail-like dimensions.
if (!measured) continue;
let preview = previews.get(root);
if (!preview?.host.isConnected) {
const host = makeControl('thumbnail', () => {
const current = previews.get(root);
const live = current?.link?.isConnected ? canonical(current.link.href) : null;
return live || [...root.querySelectorAll('a[href]')].map(anchor => canonical(anchor.href)).find(Boolean) || current?.url;
});
host.className = 'ytdl-thumbnail-download';
const key = `ytdl-preview-${++previewSequence}`; root.dataset.ytdlPreview = key; host.dataset.ytdlPreviewFor = key;
host.style.cssText = `position:${card ? 'fixed' : 'absolute'};left:0;top:0;width:74px;height:34px;margin:0;padding:0;line-height:1;z-index:2147483644;display:inline-flex`;
if (card) document.documentElement.append(host);
else {
if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
if (getComputedStyle(root).display === 'inline') root.style.display = 'block';
root.append(host);
}
preview = { root, host, floating: Boolean(card), url, link };
previews.set(root, preview); previewLayouts.add(preview); previewResize.observe(root);
}
preview.url = url; preview.link = link; observePicture(preview, measured.element); positionPreview(preview);
}
schedulePreviewLayout();
}
function attachShorts() {
if (!location.pathname.startsWith('/shorts/') || !canonical(location.href)) {
shortsButton?.remove(); shortsButton = null; shortsRenderer = null; return;
}
const renderer = document.querySelector('ytd-reel-video-renderer[is-active], ytd-reel-video-renderer[active], ytd-shorts ytd-reel-video-renderer, ytm-reel-video-renderer');
const rail = renderer?.querySelector('reel-action-bar-view-model, #actions, #actions-container, .action-container, .reel-player-overlay-actions');
if (shortsButton?.isConnected && renderer === shortsRenderer && (!rail || shortsButton.parentElement === rail)) return;
shortsButton?.remove(); shortsRenderer = renderer;
shortsButton = makeControl('shorts', () => canonical(location.href)); shortsButton.id = 'ytdl-shorts-download';
if (rail) {
shortsButton.style.cssText = 'display:flex;justify-content:center;margin-top:16px;flex-shrink:0';
rail.append(shortsButton);
} else {
shortsButton.style.cssText = 'position:fixed;right:16px;bottom:180px;z-index:2147483645;display:inline-flex';
document.documentElement.append(shortsButton);
}
}
function attach() { attachWatch(); attachThumbnails(); attachShorts(); theme(); }
chrome.runtime.onMessage.addListener(message => { if (message.type === 'CLOSE_MENU') closeMenu(); });
document.addEventListener('pointerdown', event => {
if (menu && !event.composedPath().includes(menu) && !event.composedPath().includes(menuAnchor)) closeMenu();
}, true);
document.addEventListener('keydown', event => { if (event.key === 'Escape') closeMenu(); });
window.addEventListener('resize', () => { positionMenu(); schedulePreviewLayout(); });
window.addEventListener('scroll', () => { positionMenu(); schedulePreviewLayout(); }, { passive: true, capture: true });
document.addEventListener('yt-navigate-finish', () => { closeMenu(); attach(); });
window.addEventListener('popstate', () => { closeMenu(); attach(); });
let scheduled = false;
new MutationObserver(records => {
const relevant = records.filter(record => !record.target.closest?.('[data-ytdl-control], #ytdl-menu, #ytdl-notifications'));
if (!relevant.length) return;
schedulePreviewLayout();
if (!relevant.some(record => record.type === 'childList' || ['dark', 'is-active', 'href'].includes(record.attributeName))) return;
if (!scheduled) { scheduled = true; setTimeout(() => { scheduled = false; attach(); }, 200); }
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['dark', 'is-active', 'href', 'class', 'style', 'hidden'] });
const style = document.createElement('style');
style.textContent = '@keyframes ytdl-menu-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.ytdl-closing{opacity:0;transform:translateY(-4px);transition:opacity .15s,transform .15s}@media(prefers-reduced-motion:reduce){#ytdl-menu{animation:none!important;transition:none!important}}';
document.documentElement.append(style); attach();
})();
+108
View File
@@ -0,0 +1,108 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>YouTubeDL</title><script src="theme-init.js"></script><link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="app">
<header class="header">
<div class="brand"><span class="brand-icon">↓</span><div><strong>YouTubeDL</strong><span>Видео. Звук. Ваш выбор.</span></div></div>
<div class="header-tools"><button id="open-setup" class="icon-button help-button" title="Настройка и подсказки" aria-label="Открыть мастер настройки">?</button><button id="theme" class="icon-button" title="Тема: автоматически" aria-label="Сменить тему">◐</button><button id="close" class="icon-button embedded-only" title="Закрыть меню" aria-label="Закрыть">×</button></div>
</header>
<details id="helper-panel" class="helper-panel" data-state="checking">
<summary><span class="helper-indicator"><span class="status-dot"></span><span id="helper-status" role="status" aria-live="polite">Проверяем помощник…</span></span><span class="helper-chevron">⌄</span></summary>
<div class="helper-body">
<p id="helper-message">Проверяю связь с локальным помощником.</p>
<p id="helper-versions" class="version-status" role="status"></p>
<div class="helper-actions"><button id="helper-start" type="button" class="small-button">Запустить</button><button id="helper-update" type="button" class="small-button" hidden>Обновить компоненты</button><a id="helper-install" class="small-button" href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest" target="_blank" rel="noopener noreferrer">Скачать помощник</a><button id="helper-remove" type="button" class="small-button danger-button" disabled>Удалить</button></div>
<label class="auto-update" id="auto-update-row" hidden><input id="auto-update" type="checkbox"><span>Обновлять компоненты автоматически</span></label>
<p id="update-note" class="hint" hidden>Проверка раз в сутки, пока помощник работает. Обновления устанавливаются между загрузками.</p>
<p id="helper-install-note" class="hint" hidden>На странице релизов проекта скачайте YouTubeDL-Helper-Setup.exe и запустите его. Все компоненты уже внутри. Затем вернитесь сюда и нажмите «Запустить».</p>
<details class="dependencies"><summary class="dependency-heading">Состав комплекта <span id="dependencies-status">Ещё не проверен</span></summary><div id="dependencies"></div></details>
</div>
</details>
<section id="session-panel" class="session-panel" data-state="checking" aria-label="Вход в YouTube">
<div class="session-heading"><span class="session-dot"></span><span id="session-status" role="status" aria-live="polite">Проверяем вход в YouTube…</span></div>
<p id="session-message" class="hint">Используется вход из этого профиля браузера.</p>
<div class="session-actions"><button id="session-enable" type="button" class="small-button">Использовать вход в YouTube</button><button id="session-disable" type="button" class="text-button" hidden>Отключить</button></div>
</section>
<section class="source">
<span class="eyebrow">СЕЙЧАС НА YOUTUBE</span>
<h1 id="video-title">Подключаюсь к помощнику…</h1>
<p id="video-meta" class="muted">Выберите качество и сохраните ролик в один клик.</p>
<div id="manual-url" hidden><label for="url-input">Ссылка на ролик</label><div class="url-row"><input id="url-input" type="url" placeholder="https://www.youtube.com/watch?v=…"><button id="load-url" class="small-button">Открыть</button></div></div>
</section>
<div id="alert" class="alert" role="alert" hidden></div>
<div id="error-actions" class="error-actions" hidden><button id="retry-video" type="button" class="small-button">Повторить запрос качества</button></div>
<div id="loading" class="loading"><span class="spinner"></span><span id="loading-text">Проверяю подключение…</span></div>
<form id="download-form" hidden>
<fieldset class="mode-switch" aria-label="Что скачать">
<label><input type="radio" name="mode" value="video" checked><span>Видео + звук</span></label>
<label><input type="radio" name="mode" value="video_only"><span>Видео</span></label>
<label><input type="radio" name="mode" value="audio"><span>Аудио</span></label>
</fieldset>
<div id="video-options" class="field"><label for="quality">Качество видео <button id="refresh" type="button" class="text-button" title="Обновить дорожки">Обновить</button></label><select id="quality"></select></div>
<div id="audio-options" class="field"><label for="audio-quality">Звуковая дорожка</label><select id="audio-quality"></select><p id="audio-note" class="hint">Звук автоматически добавится к видео.</p></div>
<div class="two-columns">
<div class="field"><label for="container">Формат файла</label><select id="container"></select></div>
<div class="field" id="codec-field"><label for="codec">Видеокодек</label><select id="codec"><option value="auto">Авто · быстрее всего</option><option value="copy">Сохранить исходный</option><option value="h264">H.264 · совместимость</option><option value="hevc">HEVC · компактнее</option><option value="av1">AV1 · современный</option><option value="vp9">VP9 · WebM</option></select></div>
<div class="field" id="bitrate-field" hidden><label for="bitrate">Битрейт аудио</label><select id="bitrate"><option value="128">128 кбит/с</option><option value="192">192 кбит/с</option><option value="256" selected>256 кбит/с</option><option value="320">320 кбит/с</option></select></div>
</div>
<details class="advanced"><summary>Ускорение и имя файла <span>⌄</span></summary><div class="advanced-body">
<div class="two-columns"><div class="field"><label for="engine">Обработка</label><select id="engine"><option value="auto">Авто · GPU при конвертации</option><option value="nvenc">Только NVIDIA NVENC</option><option value="amf">Только AMD AMF</option><option value="cpu">CPU</option></select></div><div class="field"><label for="preset">Приоритет</label><select id="preset"><option value="fast">Скорость</option><option value="balanced" selected>Баланс</option><option value="quality">Качество</option></select></div></div>
<div class="field"><label for="filename">Имя файла <span class="optional">необязательно</span></label><input id="filename" maxlength="150" placeholder="Название ролика"></div>
</div></details>
<div class="field folder-field"><label for="folder">Сохранить в</label><div class="folder-row"><input id="folder" placeholder="Выберите папку" aria-label="Папка сохранения"><button id="pick-folder" type="button" class="small-button" title="Выбрать папку">Выбрать…</button></div></div>
<div class="field folder-field"><label for="work-folder">Рабочая папка для загрузки и обработки</label><div class="folder-row"><input id="work-folder" placeholder="Та же папка, что и для сохранения" aria-label="Рабочая папка"><button id="pick-work-folder" type="button" class="small-button" title="Выбрать диск и рабочую папку">Выбрать…</button></div><p class="hint">Можно выбрать другой диск. Здесь будут дорожки и временные файлы обработки; готовый файл попадёт в папку «Сохранить в». Изменение применяется к следующим загрузкам.</p></div>
<div id="processing-hint" class="processing-hint"></div>
<div class="form-actions"><button id="save-settings" type="button" class="small-button">Сохранить настройки</button><button id="download" type="submit" class="download-button">Скачать <span id="estimate" class="estimate"></span></button></div>
</form>
<section id="active-jobs-section" class="jobs-section" hidden><div class="section-label">ТЕКУЩИЕ ОПЕРАЦИИ</div><div id="active-jobs"></div></section>
<section class="history-link"><button id="open-history" type="button" class="small-button">История загрузок и ошибок <span aria-hidden="true">↗</span></button><p class="hint">Откроется в новой вкладке.</p></section>
<footer><span id="hardware">Видеокарта ещё не проверена</span></footer>
</main>
<dialog id="setup" class="setup" aria-labelledby="setup-title">
<div class="setup-header"><span class="eyebrow">ПЕРВЫЙ ЗАПУСК</span><button id="setup-close" type="button" class="icon-button" aria-label="Пропустить настройку" title="Продолжить позже">×</button></div>
<ol class="setup-progress" aria-label="Этапы настройки"><li>Знакомство</li><li>Помощник</li><li>Настройки</li><li>Готово</li></ol>
<div class="setup-body">
<h2 id="setup-title" tabindex="-1">Сохраняйте ролики в один клик</h2>
<section data-setup-step="0">
<p>Сначала подключим помощник и выберем, как сохранять видео. Это займёт несколько шагов.</p>
<div class="setup-preview" aria-hidden="true"><span class="setup-example-download">↓ Скачать</span><span class="setup-example-settings">⚙</span></div>
<p><strong>Загрузчик</strong> использует сохранённые настройки. <strong>«Скачать»</strong> запускает загрузку, а <strong>⚙ рядом</strong> открывает выбор качества, формата и папки.</p>
<div class="setup-tip">Кнопки появятся рядом с «Подписаться», на превью роликов и в Shorts.</div>
</section>
<section data-setup-step="1" hidden>
<p>Помощник скачивает дорожки, соединяет видео со звуком и конвертирует файл на вашем компьютере.</p>
<div id="setup-helper" class="setup-helper" data-state="checking"><span class="status-dot"></span><strong id="setup-helper-status" role="status" aria-live="polite">Проверяем помощник…</strong></div>
<p id="setup-helper-message" class="hint"></p>
<p id="setup-versions" class="version-status" role="status"></p>
<div class="setup-helper-actions"><a id="setup-install" class="small-button" href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest" target="_blank" rel="noopener noreferrer">Скачать в релизах</a><button id="setup-check" type="button" class="small-button">Проверить подключение</button></div>
<ol id="setup-install-steps" class="setup-instructions"><li>На странице релизов проекта скачайте YouTubeDL-Helper-Setup.exe и откройте его из загрузок браузера.</li><li>Дождитесь окончания установки — все компоненты уже внутри.</li><li>Вернитесь сюда и нажмите «Проверить подключение».</li></ol>
<p id="setup-gpu" class="setup-tip" hidden></p>
<label id="setup-updates-row" class="setup-checkbox" hidden><input id="setup-updates" type="checkbox"><span>Обновлять компоненты автоматически</span></label>
<p id="setup-updates-note" class="hint" hidden>Раз в сутки, пока помощник работает, между загрузками. Нужен интернет.</p>
</section>
<section data-setup-step="2" hidden>
<p>Эти настройки будут использоваться кнопками «Скачать» на YouTube. Позже их можно изменить через ⚙.</p>
<div class="field"><label for="setup-folder">Куда сохранять файлы</label><div class="folder-row"><input id="setup-folder" placeholder="Выберите папку"><button id="setup-pick-folder" type="button" class="small-button">Выбрать…</button></div></div>
<div class="field"><label for="setup-work-folder">Где скачивать и обрабатывать</label><div class="folder-row"><input id="setup-work-folder" placeholder="Та же папка, что и для сохранения"><button id="setup-pick-work-folder" type="button" class="small-button">Выбрать…</button></div><p class="hint">Необязательно: можно выбрать рабочую папку на другом диске.</p></div>
<div class="field"><label for="setup-mode">Что скачивать</label><select id="setup-mode"><option value="video">Видео + звук</option><option value="video_only">Только видео</option><option value="audio">Только звук</option></select></div>
<div class="two-columns"><div class="field" id="setup-quality-field"><label for="setup-quality">Качество видео</label><select id="setup-quality"><option value="best">Лучшее доступное</option><option value="2160">До 2160p · 4K</option><option value="1440">До 1440p</option><option value="1080">До 1080p · Full HD</option><option value="720">До 720p</option></select></div><div class="field"><label for="setup-format">Формат файла</label><select id="setup-format"></select></div></div>
<div class="setup-tip" id="setup-format-hint">Отдельный звук автоматически добавится к видео. Если выбранного качества нет, используем ближайшее доступное.</div>
<p class="hint">Обработка — «Авто»: подходящая видеокарта при конвертации, CPU при необходимости. Совместимые дорожки сохраняются без перекодирования.</p>
</section>
<section data-setup-step="3" hidden>
<p id="setup-saved" class="setup-saved"></p>
<ol class="setup-instructions"><li><strong>Откройте YouTube.</strong> Если страница была открыта при установке расширения, обновите её.</li><li><strong>Нажмите «Скачать» или ↓ на превью.</strong> Загрузка начнётся по вашим настройкам.</li><li><strong>Следите за уведомлением.</strong> Оно покажет скачивание, объединение дорожек и конвертацию. «Показать файл» откроет результат.</li></ol>
<div class="setup-tip">Вход в YouTube необязателен. Если YouTube попросит авторизацию, откройте ⚙ → «Использовать вход в YouTube» и разрешите доступ.</div>
<p class="hint">Этот мастер всегда доступен по кнопке ? вверху меню. Кнопка «История загрузок и ошибок» под настройками открывает отдельную вкладку с результатами и сообщениями об ошибках.</p>
</section>
<p id="setup-error" class="alert" role="alert" hidden></p>
</div>
<div class="setup-footer"><button id="setup-skip" type="button" class="text-button">Позже</button><div class="setup-navigation"><button id="setup-back" type="button" class="small-button" hidden>Назад</button><button id="setup-next" type="button" class="download-button">Начать настройку</button></div></div>
</dialog>
<script type="module" src="downloader.js"></script>
</body>
</html>
+454
View File
@@ -0,0 +1,454 @@
import { bytes, canonicalUrl, codecName, duration, progressText, downloadSizeText, processingText, preferenceOptions, errorText, updateText, checkHelperCompatibility } from "./shared.js";
import { SESSION_PERMISSION } from "./session.js";
import { createOnboarding } from "./onboarding.js";
import { createUiConnection } from "./ui-connection.js";
const $ = id => document.getElementById(id);
const query = new URLSearchParams(location.search);
const embedded = query.get("embedded") === "1";
if (embedded) document.documentElement.dataset.embedded = "true";
let url = canonicalUrl(query.get("url"));
let metadata = null;
let hardware = null;
let preference = {};
let onboarding = null;
let theme = "auto";
let pageTheme = query.get("theme") || "auto";
let jobStates = {};
let helper = { status: "checking", message: "Проверяю связь с локальным помощником." };
let helperBusy = false;
let busy = false;
let processingUnavailable = false;
let sessionBusy = false;
let session = { status: "checking" };
const sourceTabId = Number(query.get("tabId")) || null;
let previousVideoContainer = "mp4";
let previousAudioContainer = "m4a";
let previousMode = "video";
const { call } = createUiConnection({ onEvent: message => {
if (message.event === "helper") { renderHelper(message.helper); return; }
if (message.event === "remove_job") { delete jobStates[message.jobId]; renderJobs(); return; }
if (message.event === "job") { jobStates[message.job.id] = message.job; renderJobs(); return; }
if (message.event === "state") { jobStates = Object.fromEntries(message.jobs.map(job => [job.id, job])); renderJobs(); if (message.helper) renderHelper(message.helper); return; }
}, onDisconnect: () => {
renderHelper({ status: "offline", message: "Связь с расширением прервана. Нажмите «Проверить подключение» или «Запустить» — соединение восстановится автоматически." });
} });
function showError(message) {
$("alert").textContent = errorText(message || ""); $("alert").hidden = !message;
$("error-actions").hidden = !message || !url || helper.status !== "running" || hardware?.ready === false;
}
function renderSession(state = session) {
session = state;
$("session-panel").dataset.state = state.status;
$("session-status").textContent = { checking: "Проверяем вход в YouTube…", connected: "Вход браузера подключён", signed_out: "Вход в YouTube не найден",
permission_required: "Нужно разрешение на вход", disabled: "Вход в YouTube не подключён", error: "Не удалось проверить вход" }[state.status];
$("session-message").textContent = state.message || "Используется вход из этого профиля браузера.";
$("session-enable").textContent = ["connected", "signed_out"].includes(state.status) ? "Проверить вход" : "Использовать вход в YouTube";
$("session-enable").disabled = sessionBusy || busy;
$("session-disable").hidden = !["connected", "signed_out", "permission_required"].includes(state.status);
$("session-disable").disabled = sessionBusy || busy;
}
async function refreshSession() {
try { renderSession(await call("session_status", { tabId: sourceTabId })); }
catch (error) { renderSession({ status: "error", message: error.message }); }
}
$("session-enable").addEventListener("click", async () => {
if (sessionBusy) return;
sessionBusy = true; renderSession(); showError("");
try {
// Request from the click handler to preserve the browser's user gesture.
if (!["connected", "signed_out"].includes(session.status)) {
if (!await chrome.permissions.request(SESSION_PERMISSION)) throw new Error("Доступ к входу YouTube не разрешён. Вы можете разрешить его этой кнопкой позднее.");
renderSession(await call("session_enable", { enabled: true, tabId: sourceTabId }));
} else await refreshSession();
if (session.status === "connected" && url && helper.status === "running") await loadVideo();
} catch (error) { showError(error.message); }
finally { sessionBusy = false; renderSession(); }
});
$("session-disable").addEventListener("click", async () => {
sessionBusy = true; renderSession();
try { renderSession(await call("session_enable", { enabled: false, tabId: sourceTabId })); }
catch (error) { showError(error.message); }
finally { sessionBusy = false; renderSession(); }
});
$("retry-video").addEventListener("click", loadVideo);
function renderDependencies() {
const labels = { python: "Python", yt_dlp: "yt-dlp", ejs: "YouTube JS (EJS)", node: "Node.js", ffmpeg: "FFmpeg + FFprobe" };
const measured = helper.status === "running" ? hardware?.dependencies || [] : [];
$("dependencies-status").textContent = measured.length ? `${measured.filter(item => item.status === "installed").length}/${measured.length} готовы` : "Ещё не проверены";
for (const [id, label] of Object.entries(labels)) {
let row = $("dependencies").querySelector(`[data-dependency="${id}"]`);
if (!row) {
row = document.createElement("div"); row.className = "dependency-row"; row.dataset.dependency = id;
row.innerHTML = '<span class="dependency-dot"></span><div class="dependency-copy"><span class="dependency-label"></span><small class="dependency-version"></small></div>';
row.querySelector(".dependency-label").textContent = label;
$("dependencies").append(row);
}
const item = measured.find(item => item.id === id);
row.dataset.status = item?.status || "unknown";
row.querySelector(".dependency-version").textContent = item?.status === "installed" ? item.version || "Установлен" : item?.status === "outdated" ? `${item.version} · нужно обновить` : item ? "Не установлен" : "Проверка после запуска помощника";
}
}
function renderHelper(state = helper) {
if (state.status === "running") {
const compatibility = checkHelperCompatibility(state.hardware, chrome.runtime.getManifest().version);
state = { ...state, compatibility, ...(compatibility.compatible ? {} : { status: "incompatible", message: compatibility.message }) };
}
const componentsBecameReady = hardware?.ready === false && state.hardware?.ready === true;
helper = state;
const connected = state.status === "running";
const runningJobs = Object.values(jobStates).some(job => job.status === "running");
const updating = Object.values(jobStates).some(job => job.kind === "update" && job.status === "running");
$("helper-panel").dataset.state = state.status;
$("helper-status").textContent = { running: "Помощник работает", checking: "Проверяем помощник…", missing: "Помощник не установлен",
offline: "Помощник отключён", error: "Помощник не отвечает", incompatible: "Нужно обновить версии", idle: "Помощник не подключён" }[state.status] || "Помощник не подключён";
$("helper-message").textContent = connected && state.hardware?.bundle?.mode === "autonomous"
? "Автономный комплект: компоненты установлены вместе и могут обновляться из интернета." : state.message || "";
const helperVersion = state.hardware?.version || state.hardware?.bundle?.version;
updateText($("helper-versions"), `Расширение ${chrome.runtime.getManifest().version} · Помощник ${helperVersion || "не проверен"}${connected ? " · Версии совместимы" : ""}`);
$("helper-versions").dataset.state = state.status;
$("helper-start").textContent = connected || state.status === "incompatible" ? "Проверить" : "Запустить";
$("helper-start").disabled = helperBusy || state.status === "checking";
$("helper-install").textContent = state.compatibility?.update === "extension" ? "Обновить расширение" : state.compatibility?.update === "helper" ? "Обновить помощник" : connected ? state.hardware?.ready === false ? "Восстановить" : "Переустановить" : "Скачать помощник";
$("helper-install").title = state.compatibility?.update === "extension" ? "Откройте релизы, скачайте новое расширение, замените файлы и нажмите «Обновить» на странице расширений браузера." : "Открыть релизы проекта. Скачайте отдельный YouTubeDL-Helper-Setup.exe и запустите его вручную.";
const supportsUpdates = connected && state.hardware?.supportsComponentUpdates === true;
$("helper-update").hidden = !supportsUpdates;
$("helper-update").disabled = helperBusy || busy || runningJobs || state.hardware?.ready === false;
$("auto-update-row").hidden = $("update-note").hidden = !supportsUpdates;
$("auto-update").disabled = helperBusy || updating;
if (supportsUpdates) $("auto-update").checked = state.hardware.updateSettings?.enabled === true;
$("helper-install").setAttribute("aria-disabled", String(helperBusy || runningJobs));
$("helper-remove").disabled = !connected || helperBusy || runningJobs;
$("helper-remove").title = runningJobs ? "Дождитесь завершения загрузок" : "Отключить и удалить регистрацию помощника";
if (!connected) {
hardware = null;
$("download-form").hidden = true;
$("hardware").textContent = "Видеокарта ещё не проверена";
if (!["idle", "checking"].includes(state.status)) $("helper-panel").open = true;
} else {
hardware = state.hardware || hardware;
const accelerators = [["nvenc", "NVIDIA NVENC"], ["amf", "AMD AMF"]];
const checked = accelerators.filter(([key]) => hardware?.[key]?.length).map(([key, name]) => `${name}: ${hardware[key].map(codecName).join(", ")}`);
$("hardware").textContent = hardware?.gpu || "Видеокарта не определена";
$("hardware").title = checked.length ? `Проверено кодирование: ${checked.join("; ")}` : "Аппаратное кодирование недоступно. Конвертация выполняется на CPU.";
for (const [key] of accelerators) {
const option = $("engine").querySelector(`[value="${key}"]`);
option.disabled = !hardware?.[key]?.length;
if (option.disabled && $("engine").value === key) $("engine").value = "auto";
}
$("download-form").hidden = busy || !metadata || hardware?.ready === false;
}
$("download").disabled = !connected || helperBusy || busy || hardware?.ready === false || processingUnavailable
|| Object.values(jobStates).some(job => ["dependency", "update"].includes(job.kind) && job.status === "running");
renderDependencies();
onboarding?.render();
if (metadata) updateHint();
if (componentsBecameReady && url && !metadata && !busy && !helperBusy && !onboarding?.isOpen) queueMicrotask(loadVideo);
}
function loading(active, message = "Получаю доступное качество…") {
busy = active;
$("loading").hidden = !active;
$("loading-text").textContent = message;
$("download-form").hidden = active || !metadata || helper.status !== "running" || hardware?.ready === false;
renderSession();
}
function applyTheme() {
const resolved = theme === "auto" ? pageTheme : theme;
if (["light", "dark"].includes(resolved)) document.documentElement.dataset.theme = resolved;
else delete document.documentElement.dataset.theme;
$("theme").title = `Тема: ${{ auto: "автоматически", light: "светлая", dark: "тёмная" }[theme]}`;
$("theme").textContent = { auto: "◐", light: "☀", dark: "☾" }[theme];
}
window.addEventListener("message", event => {
if (embedded && event.source === parent && /^https:\/\/(www|m)\.youtube\.com$/.test(event.origin) &&
event.data?.type === "YTDL_THEME" && ["light", "dark"].includes(event.data.theme)) {
pageTheme = event.data.theme; applyTheme();
}
});
$("theme").addEventListener("click", () => {
theme = { auto: "light", light: "dark", dark: "auto" }[theme];
chrome.storage.local.set({ theme }); applyTheme();
});
$("close").addEventListener("click", () => {
if (embedded) chrome.runtime.sendMessage({ type: "CLOSE_MENU" }).catch(error => showError(error.message));
else window.close();
});
$("open-history").addEventListener("click", () => {
call("open_history", { theme: document.documentElement.dataset.theme || "auto" }).catch(error => showError(error.message));
});
document.addEventListener("keydown", event => {
if (event.key === "Escape" && !event.defaultPrevented && !onboarding?.isOpen) $("close").click();
});
function mode() { return document.querySelector('input[name="mode"]:checked').value; }
function options(select, items) {
select.replaceChildren(...items.map(([value, text]) => {
const option = document.createElement("option"); option.value = value; option.textContent = text; return option;
}));
}
function populate() {
options($("quality"), [["best", "Лучшее доступное качество"], ...metadata.videos.map(video => [video.id,
`${video.height}p${video.fps > 30 ? ` ${Math.round(video.fps)} fps` : ""} · ${codecName(video.codec)}${video.hdr && video.hdr !== "SDR" ? ` · ${video.hdr}` : ""}${video.hasAudio ? " · со звуком" : ""}${video.size ? ` · ≈${bytes(video.size)}` : ""}`])]);
options($("audio-quality"), [["best", "Лучшая доступная дорожка"], ...metadata.audios.map(audio => [audio.id,
`${codecName(audio.codec)}${audio.bitrate ? ` · ${Math.round(audio.bitrate)} кбит/с` : ""}${audio.language ? ` · ${audio.language}` : ""}${audio.note ? ` · ${audio.note}` : ""}`])]);
$("filename").placeholder = metadata.title;
const restored = preferenceOptions(metadata, preference);
$("quality").value = restored.videoId; $("audio-quality").value = restored.audioId;
updateMode();
}
function updateMode() {
const current = mode();
if (previousMode === "audio") previousAudioContainer = $("container").value || previousAudioContainer;
else previousVideoContainer = $("container").value || previousVideoContainer;
previousMode = current;
$("video-options").hidden = current === "audio";
$("audio-options").hidden = current === "video_only";
$("codec-field").hidden = current === "audio";
$("bitrate-field").hidden = current !== "audio";
if (current === "audio") {
options($("container"), [["m4a", "M4A · AAC"], ["mp3", "MP3"], ["opus", "Opus"], ["flac", "FLAC"], ["wav", "WAV"]]);
$("container").value = previousAudioContainer;
} else {
options($("container"), [["mp4", "MP4"], ["mkv", "MKV"], ["webm", "WebM"]]);
$("container").value = previousVideoContainer;
}
updateHint();
}
function updateHint() {
if (!metadata) return;
const current = mode();
const video = $("quality").value === "best" ? metadata.videos[0] : metadata.videos.find(item => item.id === $("quality").value);
const audio = $("audio-quality").value === "best" ? metadata.audios[0] : metadata.audios.find(item => item.id === $("audio-quality").value);
$("audio-quality").disabled = current === "video" && Boolean(video?.hasAudio);
$("audio-note").textContent = current === "audio" ? "Сохраняется только звук." : video?.hasAudio
? "В этой дорожке звук уже есть — он будет сохранён." : "Отдельный звук скачается и автоматически добавится к видео.";
const container = $("container").value;
const codec = $("codec").value;
const isCompatible = container === "mkv" || (container === "mp4" && ["H.264", "HEVC", "AV1"].includes(codecName(video?.codec)))
|| (container === "webm" && ["VP9", "AV1", "VP8"].includes(codecName(video?.codec)));
const outputCodec = codec === "auto" ? container === "webm" ? "av1" : video?.hdr && video.hdr !== "SDR" ? "hevc" : "h264" : codec;
const selectedEngine = $("engine").value;
const gpuEngine = ["nvenc", "amf"].find(key => ["auto", key].includes(selectedEngine) && hardware?.[key]?.includes(outputCodec)
&& !(key === "amf" && video?.hdr && video.hdr !== "SDR" && !hardware?.amf10bit?.includes(outputCodec)));
const copiesVideo = codec === "copy" || (codec === "auto" && isCompatible);
processingUnavailable = current !== "audio" && !copiesVideo && ["nvenc", "amf"].includes(selectedEngine) && !gpuEngine;
$("processing-hint").textContent = current === "audio"
? ["flac", "wav"].includes(container) ? "Без дополнительного сжатия. Исходное качество YouTube не повышается." : "Совместимый звук сохраняется без повторного кодирования."
: copiesVideo
? "Видео сохранится без перекодирования — быстро и без потери качества." : processingUnavailable
? `Выбранный кодек недоступен в ${selectedEngine === "amf" ? "AMD AMF" : "NVIDIA NVENC"}. Выберите «Авто» или другой кодек.` : gpuEngine
? `При конвертации используется ${gpuEngine === "amf" ? "AMD AMF" : "NVIDIA NVENC"}. Разрешение сохраняется.` : "Конвертация на CPU. Разрешение сохраняется.";
const estimate = current === "audio" ? audio?.size : (video?.size || 0) + (current === "video" && !video?.hasAudio ? audio?.size || 0 : 0);
$("estimate").textContent = estimate ? `≈ ${bytes(estimate)}` : "";
$("bitrate").disabled = ["flac", "wav"].includes(container);
$("download").disabled = helper.status !== "running" || helperBusy || busy || hardware?.ready === false || processingUnavailable
|| Object.values(jobStates).some(job => ["dependency", "update"].includes(job.kind) && job.status === "running");
}
async function loadVideo() {
if (!url || !hardware || busy || onboarding?.isOpen) return;
if (hardware.ready === false) { $("helper-panel").open = true; loading(false); return; }
showError(""); loading(true);
metadata = null;
$("video-title").textContent = "Получаю качество ролика…";
$("video-meta").textContent = "Помощник подключён. Запрашиваю доступные дорожки YouTube.";
try {
metadata = await call("inspect", { url, tabId: sourceTabId, refresh: true });
$("video-title").textContent = metadata.title;
$("video-meta").textContent = [metadata.channel, duration(metadata.duration)].filter(Boolean).join(" · ");
populate();
} catch (error) {
$("video-title").textContent = helper.status === "running" ? "Не удалось получить качество" : "Подключим локальный помощник";
$("video-meta").textContent = helper.status === "running" ? "Помощник подключён. YouTube не вернул дорожки для этого ролика." : "Проверьте состояние помощника выше.";
showError(error.message);
}
finally { loading(false); }
}
$("refresh").addEventListener("click", loadVideo);
$("load-url").addEventListener("click", () => {
const candidate = canonicalUrl($("url-input").value);
if (!candidate) { showError("Введите ссылку на ролик или Shorts с YouTube."); return; }
url = candidate; loadVideo();
});
$("pick-folder").addEventListener("click", async () => {
$("pick-folder").disabled = true;
try {
const result = await call("pick_folder", { current: $("folder").value });
if (result.folder) { $("folder").value = result.folder; savePreferences(); }
} catch (error) { showError(error.message); }
finally { $("pick-folder").disabled = false; }
});
$("pick-work-folder").addEventListener("click", async () => {
$("pick-work-folder").disabled = true;
try {
const result = await call("pick_folder", { current: $("work-folder").value || $("folder").value });
if (result.folder) { $("work-folder").value = result.folder; await savePreferences(); }
} catch (error) { showError(error.message); }
finally { $("pick-work-folder").disabled = false; }
});
function savePreferences() {
preference = { folder: $("folder").value, workFolder: $("work-folder").value.trim(), engine: $("engine").value, preset: $("preset").value,
videoContainer: previousVideoContainer, audioContainer: previousAudioContainer, bitrate: Number($("bitrate").value),
mode: mode(), codec: $("codec").value, videoId: $("quality").value || "best", audioId: $("audio-quality").value || "best", filename: $("filename").value.trim(),
quality: $("quality").value === "best" ? null : (() => { const video = metadata?.videos.find(video => video.id === $("quality").value); return video ? { height: video.height, fps: video.fps, codec: codecName(video.codec) } : null; })(),
audioLanguage: metadata?.audios.find(audio => audio.id === $("audio-quality").value)?.language || "" };
return chrome.storage.local.set({ preferences: preference });
}
document.querySelectorAll('input[name="mode"]').forEach(input => input.addEventListener("change", () => { updateMode(); savePreferences(); }));
for (const id of ["quality", "audio-quality", "codec", "container", "engine", "bitrate", "preset"]) $(id).addEventListener("change", () => {
if (id === "container") { if (mode() === "audio") previousAudioContainer = $(id).value; else previousVideoContainer = $(id).value; }
updateHint(); savePreferences();
});
$("folder").addEventListener("change", savePreferences);
$("work-folder").addEventListener("change", savePreferences);
$("filename").addEventListener("change", savePreferences);
$("save-settings").addEventListener("click", async () => {
await savePreferences();
if (embedded) await chrome.runtime.sendMessage({ type: "CLOSE_MENU" });
else window.close();
});
$("download-form").addEventListener("submit", async event => {
event.preventDefault(); showError("");
if (!metadata) return;
if (!$("folder").value.trim()) { showError("Сначала выберите папку сохранения."); return; }
$("download").disabled = true;
try {
await call("download", { url, title: metadata.title, tabId: Number(query.get("tabId")) || null,
mode: mode(), videoId: $("quality").value, audioId: $("audio-quality").value,
container: $("container").value, codec: $("codec").value, engine: $("engine").value,
preset: $("preset").value, bitrate: Number($("bitrate").value), folder: $("folder").value.trim(), workFolder: $("work-folder").value.trim(), filename: $("filename").value.trim() });
savePreferences();
if (embedded) await chrome.runtime.sendMessage({ type: "CLOSE_MENU" });
} catch (error) { showError(error.message); }
finally { $("download").disabled = false; }
});
function renderJobs() {
renderHelper();
const sorted = Object.values(jobStates).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
const active = sorted.filter(job => job.status === "running");
const updateJob = sorted.find(job => job.kind === "update");
const orderedCards = { "active-jobs": [] };
$("update-note").textContent = updateJob?.status === "complete" ? updateJob.result?.title || "Обновление завершено."
: updateJob?.status === "error" ? updateJob.error : "Проверка раз в сутки, пока помощник работает. Обновления устанавливаются между загрузками.";
$("active-jobs-section").hidden = !active.length;
for (const job of active) {
let card = document.querySelector(`[data-job-id="${CSS.escape(job.id)}"]`);
if (!card) {
card = document.createElement("article"); card.className = "job-card"; card.dataset.jobId = job.id;
card.innerHTML = '<div class="job-top"><div><div class="job-title"></div><div class="job-stage"></div></div><span class="job-percent"></span></div><div class="progress-track"><div class="progress-fill"></div></div><div class="job-transfer" hidden></div><div class="job-error" hidden></div><div class="job-actions"></div>';
}
orderedCards["active-jobs"].push(card);
if (card.dataset.status !== job.status) card.dataset.status = job.status;
updateText(card.querySelector(".job-title"), job.title || "Видео");
const extra = job.stage === "downloading" && Number.isFinite(job.speed) && job.speed >= 0 ? ` · ${job.speed === 0 ? "0 МБ" : bytes(job.speed)}/с` : ["merging", "remuxing", "encoding", "audio"].includes(job.stage) && job.speed ? ` · ${job.speed}` : "";
updateText(card.querySelector(".job-stage"), progressText(job) + extra);
updateText(card.querySelector(".job-percent"), job.status === "complete" ? "✓" : Number.isFinite(job.percent) ? `${Math.round(job.percent)}%` : "");
const fill = card.querySelector(".progress-fill");
fill.classList.toggle("indeterminate", job.status === "running" && !Number.isFinite(job.percent));
const width = `${Number.isFinite(job.percent) ? Math.min(100, Math.max(0, job.percent)) : 0}%`;
if (fill.style.width !== width) fill.style.width = width;
const transfer = card.querySelector(".job-transfer");
let transferText = downloadSizeText(job) || processingText(job);
if (["merging", "remuxing", "encoding", "audio"].includes(job.stage) && Number.isFinite(job.eta) && job.eta > 0) transferText += ` · осталось ≈ ${Math.ceil(job.eta)} с`;
updateText(transfer, transferText);
transfer.hidden = !transfer.textContent;
const error = card.querySelector(".job-error"); error.hidden = !job.error; updateText(error, errorText(job.error || ""));
const actions = card.querySelector(".job-actions");
if (actions.dataset.status !== job.status) {
actions.dataset.status = job.status; actions.replaceChildren();
if (["running", "complete"].includes(job.status) && job.canCancel !== false && !(["dependency", "update"].includes(job.kind) && job.status === "complete")) {
const button = document.createElement("button"); button.type = "button";
button.textContent = job.status === "complete" ? "Показать файл ↗" : "Отменить";
button.addEventListener("click", () => call(job.status === "complete" ? "reveal" : "cancel", { jobId: job.id }).catch(error => showError(error.message)));
actions.append(button);
}
}
}
for (const [id, cards] of Object.entries(orderedCards)) {
const parent = $(id);
for (const card of [...parent.children]) if (!active.some(job => job.id === card.dataset.jobId)) card.remove();
cards.forEach((card, index) => {
if (parent.children[index] !== card) parent.insertBefore(card, parent.children[index] || null);
});
}
}
async function connectHelper(action = "hello") {
if (helperBusy) return;
helperBusy = true; showError("");
renderHelper({ status: "checking", message: "Запускаю и проверяю локальный помощник…" });
try {
hardware = await call(action);
renderHelper({ status: "running", message: "Помощник отвечает. Загрузка и обработка доступны.", hardware });
$("folder").value ||= preference.folder || hardware.defaultFolder;
loading(false);
if (url && !metadata && hardware.ready !== false && !onboarding?.isOpen) await loadVideo();
else if (hardware.ready === false) { $("helper-panel").open = true; $("video-title").textContent = "Установим недостающие компоненты"; }
else if (!url) $("video-title").textContent = "Какой ролик скачать?";
} catch (error) {
if (helper.status === "checking") renderHelper({ status: "error", message: error.message });
loading(false); if (!metadata) $("video-title").textContent = "Подключим локальный помощник";
} finally { helperBusy = false; renderHelper(); }
}
$("helper-start").addEventListener("click", () => connectHelper("helper_start"));
$("helper-update").addEventListener("click", async () => {
helperBusy = true; renderHelper(); showError("");
try { await call("helper_update", { tabId: sourceTabId, title: "Обновление компонентов" }); }
catch (error) { showError(error.message); }
finally { helperBusy = false; renderHelper(); }
});
$("auto-update").addEventListener("change", async () => {
const enabled = $("auto-update").checked;
try {
const settings = await call("update_settings", { enabled });
if (hardware) hardware.updateSettings = settings;
renderHelper();
} catch (error) { renderHelper(); showError(error.message); }
});
$("helper-install").addEventListener("click", () => {
$("helper-install-note").hidden = false;
});
$("helper-remove").addEventListener("click", async () => {
helperBusy = true; renderHelper(); showError("");
try {
await call("helper_remove");
renderHelper({ status: "missing", message: "Помощник удалён. Для установки скачайте и запустите установщик." });
} catch (error) { showError(error.message); }
finally { helperBusy = false; renderHelper(); }
});
const helperTimer = setInterval(async () => {
if (!helperBusy && helper.status !== "checking") {
try { renderHelper(await call("helper_status")); } catch {}
}
}, 10000);
window.addEventListener("pagehide", () => clearInterval(helperTimer), { once: true });
function restorePreferences(saved) {
preference = saved;
$("folder").value = preference.folder || hardware?.defaultFolder || "";
$("work-folder").value = preference.workFolder || "";
$("engine").value = preference.engine || "auto"; $("preset").value = preference.preset || "balanced";
$("codec").value = preference.codec || "auto"; $("filename").value = preference.filename || "";
const preferredMode = document.querySelector(`input[name="mode"][value="${["video", "video_only", "audio"].includes(preference.mode) ? preference.mode : "video"}"]`);
preferredMode.checked = true; previousMode = mode();
$("bitrate").value = String(preference.bitrate || 256);
previousVideoContainer = preference.videoContainer || "mp4"; previousAudioContainer = preference.audioContainer || "m4a";
options($("container"), mode() === "audio" ? [["m4a", "M4A · AAC"], ["mp3", "MP3"], ["opus", "Opus"], ["flac", "FLAC"], ["wav", "WAV"]]
: [["mp4", "MP4"], ["mkv", "MKV"], ["webm", "WebM"]]);
$("container").value = mode() === "audio" ? previousAudioContainer : previousVideoContainer;
if (metadata) populate(); else updateMode();
}
async function init() {
const saved = await chrome.storage.local.get(["theme", "preferences", "onboarding"]);
theme = saved.theme || "auto"; applyTheme(); restorePreferences(saved.preferences || {});
$("manual-url").hidden = Boolean(url);
onboarding = createOnboarding({ call, connectHelper: () => connectHelper("helper_start"), getHelper: () => helper,
applyPreferences: restorePreferences, onClose: () => { if (url && !metadata && hardware?.ready) loadVideo(); } });
$("open-setup").addEventListener("click", () => onboarding.open().catch(error => showError(error.message)));
// Existing configured profiles keep their preferences and can open the guide with ?.
if ((!saved.onboarding && !saved.preferences?.folder) || query.get("setup") === "1") await onboarding.open();
await refreshSession();
await connectHelper();
if (query.get("notice")) showError(query.get("notice"));
}
init();
+11
View File
@@ -0,0 +1,11 @@
.history-app{width:min(960px,100%);padding:38px 28px 24px;margin:auto;animation:enter .25s ease-out}
.history-app .header{margin-bottom:32px}.history-app h1{font-size:32px;letter-spacing:-.8px;margin:0 0 8px;display:block}.history-intro{color:var(--muted);font-size:14px;margin:0 0 26px}
.history-tabs{display:flex;gap:8px;padding:5px;background:var(--field);border:1px solid var(--line);border-radius:14px;margin-bottom:22px;width:fit-content;max-width:100%}
.history-tabs button{display:flex;align-items:center;justify-content:center;gap:12px;border:0;border-radius:10px;background:transparent;color:var(--muted);padding:11px 20px;font:600 14px "Segoe UI",Arial,sans-serif;cursor:pointer;transition:background .18s,color .18s,box-shadow .18s}
.history-tabs button:hover{color:var(--text)}.history-tabs button[aria-selected=true]{color:var(--text);background:var(--panel);box-shadow:0 2px 7px #0002}.history-count{display:inline-grid;place-items:center;min-width:24px;height:24px;border-radius:8px;padding:0 6px;background:var(--field);font-size:12px;font-variant-numeric:tabular-nums}
.history-app button:focus-visible,.history-app a:focus-visible,[role=tabpanel]:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.history-list{display:grid;gap:12px}.history-entry{padding:20px;background:var(--panel);border:1px solid var(--line);border-radius:15px;animation:enter .2s ease-out;min-width:0}.entry-top{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}.entry-title{font-size:15px;margin:0;line-height:1.5;overflow-wrap:anywhere;min-width:0}.entry-status{border-radius:7px;background:var(--field);padding:4px 8px;color:var(--muted);white-space:nowrap;font-size:11px;flex-shrink:0}.history-entry[data-status=complete] .entry-status{color:#27b98a;background:#27b98a14}.history-entry[data-status=error] .entry-status{color:var(--danger);background:color-mix(in srgb,var(--danger) 9%,transparent)}
.entry-meta>*{min-width:0;max-width:100%;overflow-wrap:anywhere}
.entry-meta{display:flex;flex-wrap:wrap;gap:5px 16px;color:var(--muted);font-size:12px;margin-top:8px;font-variant-numeric:tabular-nums}.entry-path{font-size:12px;color:var(--muted);overflow-wrap:anywhere;margin:12px 0 0}.entry-error{font:12px/1.65 "Segoe UI",Arial,sans-serif;color:var(--danger);white-space:pre-wrap;overflow-wrap:anywhere;margin:14px 0 0;padding:12px 14px;border-radius:10px;background:color-mix(in srgb,var(--danger) 5%,var(--bg));border:1px solid color-mix(in srgb,var(--danger) 18%,var(--line))}.entry-actions{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-top:15px}.entry-actions a{color:var(--accent);font-size:12px;text-decoration:none}.entry-actions a:hover{text-decoration:underline}.entry-actions .small-button{height:34px;font-size:12px;transform:none}
.history-empty{display:flex;align-items:center;flex-direction:column;text-align:center;padding:60px 24px;border:1px dashed var(--line);border-radius:16px;background:var(--panel)}.empty-symbol{display:grid;place-items:center;width:52px;height:52px;background:var(--field);border-radius:16px;font-size:26px;color:var(--accent);margin-bottom:18px}.history-empty h2{font-size:18px;margin:0 0 8px}.history-empty p{color:var(--muted);max-width:450px;margin:0;font-size:13px;line-height:1.7}.history-footer{margin-top:24px;font-size:12px;line-height:1.7;color:var(--muted);display:block}.history-connection{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:18px;font-size:12px;color:var(--muted)}#history-alert{margin-bottom:18px}
@media(max-width:520px){.history-app{padding:22px 16px}.history-app .header{margin-bottom:26px}.history-app h1{font-size:27px}.history-intro{font-size:13px}.history-tabs{width:100%}.history-tabs button{flex:1;padding:10px 12px;gap:9px}.history-entry{padding:16px}.entry-top{flex-direction:column;gap:8px}.entry-title{font-size:14px}.entry-meta{font-size:11px;gap:5px 12px}.history-empty{padding:40px 18px}.history-connection{align-items:flex-start}}
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>История — YouTubeDL</title>
<script src="theme-init.js"></script>
<link rel="stylesheet" href="styles.css"><link rel="stylesheet" href="history.css">
</head>
<body>
<main class="history-app">
<header class="header">
<div class="brand"><span class="brand-icon" aria-hidden="true">↓</span><div><strong>YouTubeDL</strong><span>История на этом устройстве</span></div></div>
<button id="theme" type="button" class="icon-button" title="Тема: автоматически" aria-label="Сменить тему">◐</button>
</header>
<h1>История</h1>
<p class="history-intro">Скачанные файлы и сообщения об ошибках — в одном месте.</p>
<div class="history-tabs" role="tablist" aria-label="Раздел истории">
<button id="downloads-tab" type="button" role="tab" aria-selected="true" aria-controls="downloads-panel"><span>Загрузки</span><span id="downloads-count" class="history-count">0</span></button>
<button id="errors-tab" type="button" role="tab" aria-selected="false" aria-controls="errors-panel" tabindex="-1"><span>Ошибки</span><span id="errors-count" class="history-count">0</span></button>
</div>
<div id="history-alert" class="alert" role="alert" hidden></div>
<div id="history-connection" class="history-connection" hidden><span id="history-connection-message" role="status"></span><button id="history-retry" type="button" class="small-button">Повторить</button></div>
<section id="downloads-panel" role="tabpanel" aria-labelledby="downloads-tab" tabindex="0">
<div id="downloads-empty" class="history-empty"><span class="empty-symbol" aria-hidden="true">↓</span><h2>Загрузок пока нет</h2><p>Здесь появятся завершённые и отменённые скачивания. Текущий прогресс остаётся в уведомлениях и настройках.</p></div>
<div id="downloads-list" class="history-list"></div>
</section>
<section id="errors-panel" role="tabpanel" aria-labelledby="errors-tab" tabindex="0" hidden>
<div id="errors-empty" class="history-empty"><span class="empty-symbol" aria-hidden="true">✓</span><h2>Ошибок нет</h2><p>Если загрузка, получение качества или обновление компонентов завершится ошибкой, её сообщение появится здесь.</p></div>
<div id="errors-list" class="history-list"></div>
</section>
<footer class="history-footer">Записи хранятся только на этом устройстве: до 30 последних операций. Наличие записи не гарантирует, что файл остался в прежней папке.</footer>
</main>
<script type="module" src="history.js"></script>
</body>
</html>
+153
View File
@@ -0,0 +1,153 @@
import { bytes, canonicalUrl, errorText, updateText } from "./shared.js";
import { createUiConnection } from "./ui-connection.js";
const $ = id => document.getElementById(id);
const tabs = [$("downloads-tab"), $("errors-tab")];
const dateFormat = new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" });
let jobs = {};
let theme = "auto";
let call;
let refreshing = false;
let reconnectTimer;
let reconnectDelay = 1000;
function showError(message) {
updateText($("history-alert"), errorText(message || ""));
$("history-alert").hidden = !message;
}
function applyTheme() {
if (["dark", "light"].includes(theme)) document.documentElement.dataset.theme = theme;
else delete document.documentElement.dataset.theme;
const label = { auto: "автоматически", light: "светлая", dark: "тёмная" }[theme];
$("theme").title = `Тема: ${label}`;
$("theme").setAttribute("aria-label", `Сменить тему. Сейчас: ${label}`);
updateText($("theme"), { auto: "◐", light: "☀", dark: "☾" }[theme]);
}
$("theme").addEventListener("click", () => {
theme = { auto: "light", light: "dark", dark: "auto" }[theme];
applyTheme();
chrome.storage.local.set({ theme }).catch(error => showError(error.message));
});
function selectTab(index) {
tabs.forEach((tab, position) => {
const selected = position === index;
tab.setAttribute("aria-selected", String(selected));
tab.tabIndex = selected ? 0 : -1;
$(tab.getAttribute("aria-controls")).hidden = !selected;
});
}
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => selectTab(index));
tab.addEventListener("keydown", event => {
const next = { ArrowRight: (index + 1) % tabs.length, ArrowLeft: (index + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[event.key];
if (next === undefined) return;
event.preventDefault(); selectTab(next); tabs[next].focus();
});
});
function record(job) {
const card = document.createElement("article");
card.className = "history-entry"; card.dataset.jobId = job.id; card.dataset.status = job.status;
card.innerHTML = '<div class="entry-top"><h2 class="entry-title"></h2><span class="entry-status"></span></div><div class="entry-meta"></div><p class="entry-path" hidden></p><pre class="entry-error" hidden></pre><div class="entry-actions" hidden></div>';
updateText(card.querySelector(".entry-title"), job.result?.title || job.title || "Скачивание видео");
updateText(card.querySelector(".entry-status"), { complete: "Сохранено", cancelled: "Отменено", error: "Ошибка" }[job.status]);
const meta = card.querySelector(".entry-meta");
if (Number.isFinite(job.createdAt) && job.createdAt > 0) {
const date = new Date(job.createdAt * 1000);
if (Number.isFinite(date.getTime())) {
const time = document.createElement("time"); time.dateTime = date.toISOString(); time.textContent = dateFormat.format(date); meta.append(time);
}
}
for (const text of [({ update: "Обновление компонентов", dependency: "Установка компонента", inspection: "Получение качества" })[job.kind],
job.result?.filename, bytes(job.result?.size)]) {
if (!text) continue;
const span = document.createElement("span"); span.textContent = text; meta.append(span);
}
const path = card.querySelector(".entry-path");
if (job.result?.path) { path.textContent = job.result.path; path.hidden = false; }
const error = card.querySelector(".entry-error");
if (job.status === "error") { error.textContent = errorText(job.error || "Операция завершилась ошибкой."); error.hidden = false; }
const actions = card.querySelector(".entry-actions");
if (job.status === "complete" && job.result?.path) {
const reveal = document.createElement("button"); reveal.type = "button"; reveal.className = "small-button"; reveal.textContent = "Показать файл";
reveal.addEventListener("click", async () => {
reveal.disabled = true; showError("");
try { await call("reveal", { jobId: job.id }); }
catch (error) { showError(error.message); }
finally { reveal.disabled = false; }
});
actions.append(reveal);
}
const url = canonicalUrl(job.url);
if (url) {
const link = document.createElement("a"); link.href = url; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = "Открыть на YouTube ↗"; actions.append(link);
}
actions.hidden = !actions.children.length;
return card;
}
function render() {
const recent = Object.values(jobs).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).slice(0, 30);
const groups = {
downloads: recent.filter(job => ["complete", "cancelled"].includes(job.status) && (!job.kind || job.kind === "download")),
errors: recent.filter(job => job.status === "error"),
};
for (const [name, entries] of Object.entries(groups)) {
updateText($(`${name}-count`), String(entries.length));
$(`${name}-empty`).hidden = Boolean(entries.length);
const list = $(`${name}-list`);
// Terminal records do not have progress animations. Retain cards on unrelated events.
const cards = entries.map(job => {
const existing = [...list.children].find(card => card.dataset.jobId === job.id);
const signature = JSON.stringify(job);
if (existing?.dataset.signature === signature) return existing;
const card = record(job); card.dataset.signature = signature; return card;
});
for (const card of [...list.children]) if (!cards.includes(card)) card.remove();
cards.forEach((card, index) => { if (list.children[index] !== card) list.insertBefore(card, list.children[index] || null); });
}
}
function receive(message) {
if (message.event === "state") jobs = Object.fromEntries((message.jobs || []).map(job => [job.id, job]));
else if (message.event === "job") jobs[message.job.id] = message.job;
else if (message.event === "remove_job") delete jobs[message.jobId];
else return;
render();
}
function retryLater() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => { reconnectTimer = null; refresh(); }, reconnectDelay);
reconnectDelay = Math.min(30000, reconnectDelay * 2);
}
async function refresh() {
if (refreshing || !call) return;
refreshing = true; $("history-retry").disabled = true;
try {
const state = await call("state"); receive({ event: "state", ...state });
$("history-connection").hidden = true;
clearTimeout(reconnectTimer); reconnectTimer = null; reconnectDelay = 1000;
} catch (error) {
updateText($("history-connection-message"), "Показаны сохранённые записи. Не удалось обновить историю: " + errorText(error.message));
$("history-connection").hidden = false;
retryLater();
} finally { refreshing = false; $("history-retry").disabled = false; }
}
$("history-retry").addEventListener("click", refresh);
async function init() {
const saved = await chrome.storage.local.get(["jobs", "theme"]);
jobs = saved.jobs || {}; theme = ["dark", "light"].includes(saved.theme) ? saved.theme : "auto";
applyTheme(); render();
({ call } = createUiConnection({ onEvent: receive, onDisconnect: () => {
updateText($("history-connection-message"), "Связь с расширением прервалась. Сохранённая история остаётся доступна.");
$("history-connection").hidden = false;
retryLater();
} }));
chrome.storage.onChanged?.addListener((changes, area) => {
if (area !== "local" || !changes.theme) return;
theme = ["dark", "light"].includes(changes.theme.newValue) ? changes.theme.newValue : "auto"; applyTheme();
});
await refresh();
// Only re-read local state; opening history never launches the native helper.
setInterval(() => { if (!document.hidden) refresh(); }, 20000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
}
init().catch(error => showError(error.message));
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

+76
View File
@@ -0,0 +1,76 @@
{
"manifest_version": 3,
"name": "YouTubeDL",
"description": "Скачать видео и аудио с YouTube: качество, формат, папка, NVIDIA NVENC и AMD AMF.",
"version": "1.4.5",
"minimum_chrome_version": "120",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6bCUFVUmC9OqpL0+/vrQDKEesuiylybHxRWJXfMV7VfctMbDjAQzActxImzMGIviC8PJrCHT86v/erppUhIgqFrSftHVPPWmYbVJ/g6KBu1bRNIG5J1WDUnMO2E4hGNEJQe+6ezqGuGdk659qQOqP5khGbnog9wUs+1nPpolaXbXsUudn5leaxJ0SrdFT9oWjq59bAc7raEKjR1chU9pB9g9alEA8lqpcKUU8j7f/KJC9YvkOlTNEsLXhuLLzcC7Aw/eI3lgBqBLFWIcrEYrBPVFeIlNaVLdl6b8YqAD+eBaT1xdpT6VbGh/d3lf/Vq/TBzTzCplcMO2vIFGDKTNhQIDAQAB",
"permissions": [
"nativeMessaging",
"storage",
"notifications",
"activeTab"
],
"optional_permissions": [
"cookies"
],
"optional_host_permissions": [
"https://*.youtube.com/*"
],
"host_permissions": [
"https://www.youtube.com/*",
"https://m.youtube.com/*",
"https://music.youtube.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "Скачать с YouTube",
"default_icon": {
"16": "icons/16.png",
"32": "icons/32.png"
}
},
"icons": {
"16": "icons/16.png",
"32": "icons/32.png",
"48": "icons/48.png",
"128": "icons/128.png"
},
"content_scripts": [
{
"matches": [
"https://www.youtube.com/*",
"https://m.youtube.com/*"
],
"js": [
"controls.js",
"content.js"
],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": [
"downloader.html",
"downloader.js",
"onboarding.js",
"ui-connection.js",
"theme-init.js",
"shared.js",
"session.js",
"styles.css"
],
"matches": [
"https://www.youtube.com/*",
"https://m.youtube.com/*"
]
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'none'"
}
}
+147
View File
@@ -0,0 +1,147 @@
// First-run guidance uses the same native connection and preferences as downloads.
export function createOnboarding({ call, connectHelper, getHelper, applyPreferences, onClose }) {
const $ = id => document.getElementById(id);
const dialog = $("setup");
let step = 0, working = false, saved = {};
let videoFormat = "mp4", audioFormat = "m4a";
function error(message = "") {
$("setup-error").textContent = message;
$("setup-error").hidden = !message;
}
function formats(remember = true) {
if (remember && $("setup-format").value) {
if ($("setup-format").dataset.mode === "audio") audioFormat = $("setup-format").value;
else videoFormat = $("setup-format").value;
}
const audio = $("setup-mode").value === "audio";
const items = audio ? [["m4a", "M4A · AAC"], ["mp3", "MP3"], ["opus", "Opus"], ["flac", "FLAC"], ["wav", "WAV"]]
: [["mp4", "MP4"], ["mkv", "MKV"], ["webm", "WebM"]];
$("setup-format").replaceChildren(...items.map(([value, label]) => new Option(label, value)));
$("setup-format").dataset.mode = audio ? "audio" : "video";
$("setup-format").value = audio ? audioFormat : videoFormat;
$("setup-quality-field").hidden = audio;
$("setup-format-hint").textContent = audio ? "Сохранится только звуковая дорожка. Формат не повышает исходное качество звука."
: $("setup-mode").value === "video_only" ? "Сохранится видео без звука. Если выбранного качества нет, используем ближайшее доступное."
: "Отдельный звук автоматически добавится к видео. Если выбранного качества нет, используем ближайшее доступное.";
}
function ready() { const helper = getHelper(); return helper.status === "running" && helper.hardware?.ready === true; }
function render() {
const helper = getHelper(), hardware = helper.hardware;
const checking = helper.status === "checking";
$("setup-helper").dataset.state = ready() ? "running" : helper.status;
$("setup-helper-status").textContent = ready() ? "Помощник и компоненты готовы" : checking ? "Проверяем помощник…"
: helper.status === "incompatible" ? "Нужно обновить версии" : helper.status === "running" ? "Комплект нужно восстановить" : helper.status === "missing" ? "Нужно установить помощник" : "Подключите помощник";
const dependencies = hardware?.dependencies || [];
$("setup-helper-message").textContent = ready()
? `${dependencies.filter(item => item.status === "installed").length}/${dependencies.length} компонентов готовы. Дополнительные программы устанавливать не нужно.`
: helper.message || "Скачайте установщик, установите комплект и проверьте подключение.";
const helperVersion = hardware?.version || hardware?.bundle?.version;
$("setup-versions").textContent = `Расширение ${chrome.runtime.getManifest().version} · Помощник ${helperVersion || "не проверен"}${ready() ? " · Версии совместимы" : ""}`;
$("setup-versions").dataset.state = helper.status;
$("setup-install").hidden = ready();
$("setup-install-steps").hidden = ready() || helper.compatibility?.update === "extension";
$("setup-install").textContent = helper.compatibility?.update === "extension" ? "Обновить расширение" : helper.compatibility?.update === "helper" ? "Обновить помощник" : helper.status === "running" && !ready() ? "Скачать для восстановления" : "Скачать в релизах";
$("setup-check").disabled = working || checking;
$("setup-pick-folder").disabled = working || !ready();
$("setup-pick-work-folder").disabled = working || !ready();
$("setup-check").textContent = checking ? "Проверяем…" : "Проверить подключение";
$("setup-gpu").hidden = !ready();
if (ready()) {
const engines = [hardware.nvenc?.length && "NVIDIA NVENC", hardware.amf?.length && "AMD AMF"].filter(Boolean);
$("setup-gpu").textContent = `${hardware.gpu || "Видеокарта не определена"}. ${engines.length ? `Проверено ускорение: ${engines.join(", ")}.` : "Ускорение недоступно, конвертация будет выполняться на CPU."}`;
}
const updates = ready() && hardware.supportsComponentUpdates === true;
$("setup-updates-row").hidden = $("setup-updates-note").hidden = !updates;
$("setup-updates").disabled = working;
if (!working) $("setup-updates").checked = hardware?.updateSettings?.enabled === true;
$("setup-back").hidden = step === 0;
$("setup-back").disabled = working;
$("setup-next").disabled = working || ([1, 2].includes(step) && !ready());
$("setup-next").textContent = ["Начать настройку", "Продолжить", "Сохранить и продолжить", "Готово"][step];
$("setup-skip").hidden = step === 3;
$("setup-close").disabled = $("setup-skip").disabled = working;
// A typed or selected destination is never replaced by a health refresh.
if (!$("setup-folder").value && ready()) $("setup-folder").value = saved.folder || hardware.defaultFolder || "";
}
function showStep(next) {
step = next; error();
const titles = ["Сохраняйте ролики в один клик", "Подключим локальный помощник", "Выберите настройки скачивания", "Всё готово к скачиванию"];
$("setup-title").textContent = titles[step];
for (const section of dialog.querySelectorAll("[data-setup-step]")) section.hidden = Number(section.dataset.setupStep) !== step;
for (const [index, item] of [...dialog.querySelectorAll(".setup-progress li")].entries()) {
item.dataset.state = index < step ? "complete" : index === step ? "current" : "pending";
if (index === step) item.setAttribute("aria-current", "step"); else item.removeAttribute("aria-current");
}
$("setup-saved").textContent = `Файлы будут сохраняться в ${saved.folder || "выбранную папку"}.${saved.workFolder ? ` Загрузка и обработка — в ${saved.workFolder}.` : ""}`;
$("setup-title").focus(); $("setup-title").parentElement.scrollTop = 0; render();
}
async function close(state) {
if (working) return;
try {
await chrome.storage.local.set({ onboarding: { version: 1, state, finishedAt: Date.now() } });
dialog.close(); onClose();
} catch (failure) { error(`Не удалось сохранить настройку: ${failure.message}`); }
}
async function run(task) {
if (working) return;
working = true; error(); render();
try { await task(); } catch (failure) { error(failure.message); }
finally { working = false; render(); }
}
$("setup-check").addEventListener("click", () => run(connectHelper));
$("setup-install").addEventListener("click", () => {
$("setup-helper-message").textContent = "Скачайте YouTubeDL-Helper-Setup.exe на открывшейся странице релизов проекта, запустите его и вернитесь сюда для проверки подключения.";
});
$("setup-updates").addEventListener("change", () => {
const enabled = $("setup-updates").checked;
run(async () => {
const settings = await call("update_settings", { enabled });
if (getHelper().hardware) getHelper().hardware.updateSettings = settings;
});
});
$("setup-pick-folder").addEventListener("click", () => run(async () => {
const result = await call("pick_folder", { current: $("setup-folder").value });
if (result.folder) $("setup-folder").value = result.folder;
}));
$("setup-pick-work-folder").addEventListener("click", () => run(async () => {
const result = await call("pick_folder", { current: $("setup-work-folder").value || $("setup-folder").value });
if (result.folder) $("setup-work-folder").value = result.folder;
}));
$("setup-mode").addEventListener("change", () => formats());
$("setup-back").addEventListener("click", () => showStep(step - 1));
$("setup-next").addEventListener("click", async () => {
if (working || ([1, 2].includes(step) && !ready())) return;
if (step === 3) { await close("complete"); return; }
if (step === 2) {
if (!$("setup-folder").value.trim()) { error("Выберите папку для сохранения файлов."); $("setup-folder").focus(); return; }
await run(async () => {
formats();
const quality = $("setup-quality").value;
const preferences = { ...saved, folder: $("setup-folder").value.trim(), workFolder: $("setup-work-folder").value.trim(), mode: $("setup-mode").value,
videoContainer: videoFormat, audioContainer: audioFormat, codec: "auto", engine: "auto", preset: "balanced",
bitrate: saved.bitrate || 256, videoId: quality === "best" ? "best" : "setup-quality", audioId: "best",
quality: quality === "best" ? null : { height: Number(quality), fps: 30, codec: "" } };
await chrome.storage.local.set({ preferences }); saved = preferences;
applyPreferences(preferences); showStep(3);
});
} else showStep(step + 1);
});
for (const id of ["setup-close", "setup-skip"]) $(id).addEventListener("click", () => close("skipped"));
dialog.addEventListener("cancel", event => { event.preventDefault(); close("skipped"); });
return {
get isOpen() { return dialog.open; },
render,
async open() {
if (dialog.open) return;
const stored = await chrome.storage.local.get("preferences"); saved = stored.preferences || {};
videoFormat = saved.videoContainer || "mp4"; audioFormat = saved.audioContainer || "m4a";
$("setup-folder").value = saved.folder || getHelper().hardware?.defaultFolder || "";
$("setup-work-folder").value = saved.workFolder || "";
$("setup-mode").value = ["video", "video_only", "audio"].includes(saved.mode) ? saved.mode : "video";
$("setup-quality").value = saved.videoId !== "best" && saved.quality?.height ? String(saved.quality.height) : "best";
if (!$("setup-quality").value) $("setup-quality").value = "best";
formats(false); dialog.showModal(); showStep(0);
},
};
}
+22
View File
@@ -0,0 +1,22 @@
// Browser session data stays in memory and is sent only to the local native host.
export const SESSION_PERMISSION = { permissions: ["cookies"], origins: ["https://*.youtube.com/*"] };
export const isYouTubeDomain = domain => /^(?:[a-z0-9-]+\.)*youtube\.com$/i.test(String(domain || "").replace(/^\./, ""));
const loginCookie = /^(?:SAPISID|SID|LOGIN_INFO|__Secure-(?:1P|3P)(?:SID|APISID))$/;
export async function readSession(tabId = null, includeCookies = false) {
const { youtubeSession = false } = await chrome.storage.local.get("youtubeSession");
if (!youtubeSession) return { status: "disabled", message: "Открытые ролики скачиваются без входа. При необходимости можно подключить текущий профиль браузера." };
if (!await chrome.permissions.contains(SESSION_PERMISSION)) return { status: "permission_required", message: "Разрешите расширению использовать вход в YouTube." };
const stores = Number.isInteger(tabId) ? await chrome.cookies.getAllCookieStores() : [];
const storeId = stores.find(store => store.tabIds.includes(tabId))?.id;
if (Number.isInteger(tabId) && !storeId) throw new Error("Не удалось определить профиль вкладки YouTube. Откройте меню заново.");
const cookies = (await chrome.cookies.getAll({ domain: "youtube.com", ...(storeId ? { storeId } : {}) }))
.filter(cookie => isYouTubeDomain(cookie.domain) && !cookie.partitionKey && cookie.value
&& (!cookie.expirationDate || cookie.expirationDate > Date.now() / 1000))
.map(({ name, value, domain, path, secure, httpOnly, hostOnly, expirationDate }) =>
({ name, value, domain, path, secure, httpOnly, hostOnly, ...(expirationDate ? { expirationDate } : {}) }));
const connected = cookies.some(cookie => loginCookie.test(cookie.name));
return { status: connected ? "connected" : "signed_out", message: connected
? "Вход браузера передаётся локальному помощнику при каждой загрузке."
: "В этом профиле вход не найден. Войдите на YouTube и нажмите «Проверить вход».", ...(includeCookies ? { cookies } : {}) };
}
+126
View File
@@ -0,0 +1,126 @@
export const HOST_NAME = "com.youtubedl.native";
export function updateText(node, value) {
if (node.textContent === value) return;
if (node.childNodes.length === 1 && node.firstChild.nodeType === 3) node.firstChild.data = value;
else node.textContent = value;
}
export function stabilizeDownloadSpeed(job, previous, now = Date.now()) {
if (job.status !== "running" || job.stage !== "downloading") return { ...job, speedUpdatedAt: null };
if (Number.isFinite(job.speed) && job.speed >= 0) return { ...job, speedUpdatedAt: now };
const recent = previous?.status === "running" && previous.stage === job.stage && previous.track === job.track
&& Number.isFinite(previous.speed) && previous.speed >= 0 && Number.isFinite(previous.speedUpdatedAt)
&& now >= previous.speedUpdatedAt && now - previous.speedUpdatedAt < 1500;
return { ...job, speed: recent ? previous.speed : null, speedUpdatedAt: recent ? previous.speedUpdatedAt : null };
}
export const SUPPORTED_HELPER_VERSION = "1.3.2";
const MINIMUM_EXTENSION_VERSION = "1.4.5";
function compareVersions(left, right) {
if (![left, right].every(value => typeof value === "string" && /^\d+(?:\.\d+){1,3}$/.test(value))) return null;
const a = left.split(".").map(Number), b = right.split(".").map(Number);
for (let index = 0; index < Math.max(a.length, b.length); index++) {
const difference = (a[index] || 0) - (b[index] || 0);
if (difference) return Math.sign(difference);
}
return 0;
}
export function checkHelperCompatibility(hardware, extensionVersion) {
const helperVersion = hardware?.version || hardware?.bundle?.version;
const helperOrder = compareVersions(helperVersion, SUPPORTED_HELPER_VERSION);
const minimumExtension = hardware?.minimumExtensionVersion || MINIMUM_EXTENSION_VERSION;
const extensionOrder = compareVersions(extensionVersion, minimumExtension);
const versions = { helperVersion: helperVersion || "не определена", extensionVersion };
if (helperOrder === null) return { ...versions, compatible: false, update: "helper", message: "Не удалось определить версию помощника. Установите актуальный помощник и проверьте подключение." };
if (extensionOrder === null) return { ...versions, compatible: false, update: "extension", message: "Не удалось проверить совместимость версий. Обновите расширение и проверьте подключение." };
if (helperOrder < 0) return { ...versions, compatible: false, update: "helper", message: `Помощник ${helperVersion} устарел для расширения ${extensionVersion}. Установите помощник ${SUPPORTED_HELPER_VERSION} и нажмите «Проверить». Обновление компонентов не обновляет сам помощник.` };
if (extensionOrder < 0) return { ...versions, compatible: false, update: "extension", message: `Для помощника ${helperVersion} нужно расширение ${minimumExtension} или новее. Обновите расширение и перезагрузите вкладку YouTube.` };
if (helperOrder > 0) return { ...versions, compatible: false, update: "extension", message: `Помощник ${helperVersion} новее версии, поддерживаемой расширением ${extensionVersion}. Обновите расширение и перезагрузите вкладку YouTube.` };
return { ...versions, compatible: true, update: null, message: "Версии совместимы" };
}
export function errorText(message = "") {
if (/sign in to confirm|not a bot|cookies.*authentication|login required/i.test(message)) {
return "YouTube отклонил запрос загрузчика и просит проверку на бота. Повторите запрос или обновите yt-dlp в компонентах. Дополнительно можно подключить вход кнопкой «Использовать вход в YouTube». Помощник работает; переустановка не нужна.";
}
return String(message);
}
export const STAGES = {
preparing: "Подготовка", downloading: "Скачивание", merging: "Объединение дорожек", remuxing: "Подготовка файла", finalizing: "Финализация файла",
encoding: "Перекодирование", audio: "Обработка аудио", saving: "Сохранение файла", installing: "Установка", updating: "Обновление компонентов", complete: "Готово", error: "Ошибка", cancelled: "Отменено", cancelling: "Останавливаю обработку",
};
export function canonicalUrl(value) {
try {
const url = new URL(value);
if (url.protocol !== "https:" || url.username || url.password || (url.port && url.port !== "443")) return null;
let id;
const parts = url.pathname.split("/").filter(Boolean);
if (url.hostname === "youtu.be" && parts.length === 1) id = parts[0];
else if (["www.youtube.com", "youtube.com", "m.youtube.com", "music.youtube.com"].includes(url.hostname)) {
if (url.pathname === "/watch") id = url.searchParams.get("v");
else if (parts.length === 2 && ["shorts", "live", "embed"].includes(parts[0])) id = parts[1];
}
return /^[\w-]{11}$/.test(id || "") ? `https://www.youtube.com/watch?v=${id}` : null;
} catch { return null; }
}
export function bytes(value) {
if (!Number.isFinite(value) || value <= 0) return "";
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
const power = Math.min(4, Math.floor(Math.log(value) / Math.log(1024)));
return `${(value / 1024 ** power).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ${units[power]}`;
}
export function duration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "";
const rounded = Math.floor(seconds);
return rounded >= 3600 ? `${Math.floor(rounded / 3600)}:${String(Math.floor(rounded / 60) % 60).padStart(2, "0")}:${String(rounded % 60).padStart(2, "0")}`
: `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, "0")}`;
}
export function codecName(value = "") {
if (/^(avc|h264)/i.test(value)) return "H.264";
if (/^(hev|hvc|hevc)/i.test(value)) return "HEVC";
if (/^(av01|av1)/i.test(value)) return "AV1";
if (/^vp0?9/i.test(value)) return "VP9";
if (/^(mp4a|aac)/i.test(value)) return "AAC";
return value.split(".")[0].toUpperCase();
}
export function progressText(job) {
const stage = ["updating", "merging", "remuxing", "finalizing", "encoding", "audio"].includes(job.stage) && job.message ? job.message : STAGES[job.stage] || "Обработка";
const track = job.stage === "downloading" ? (job.track === "audio" ? " · аудио" : " · видео") : "";
const engine = ["encoding", "audio"].includes(job.stage) && job.engine ? ` · ${{ nvenc: "NVIDIA NVENC", amf: "AMD AMF", cpu: "CPU", copy: "без перекодирования" }[job.engine] || job.engine}` : ["merging", "remuxing"].includes(job.stage) && !job.message ? " · без перекодирования" : "";
return `${stage}${track}${engine}`;
}
export function downloadSizeText(job) {
const megabytes = value => `${(value / 1048576).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} МБ`;
if (job.status === "complete" && Number.isFinite(job.result?.size) && job.result.size >= 0) return `Размер файла: ${megabytes(job.result.size)}`;
const value = job.stage === "saving" ? job.copied : job.downloaded;
if (!["downloading", "saving"].includes(job.stage) || !Number.isFinite(value) || value < 0) return "";
const downloaded = `${job.stage === "saving" ? "Сохранено" : "Скачано"} ${megabytes(value)}`;
return Number.isFinite(job.total) && job.total > 0 ? `${downloaded} из ${megabytes(job.total)}` : downloaded;
}
export function processingText(job) {
if (!["merging", "remuxing", "finalizing", "encoding", "audio"].includes(job.stage)) return "";
const parts = [];
if (Number.isFinite(job.processedSeconds) && job.processedSeconds >= 0) parts.push(`Обработано ${duration(job.processedSeconds)}${job.durationSeconds > 0 ? ` из ${duration(job.durationSeconds)}` : ""}`);
if (Number.isFinite(job.written) && job.written >= 0) parts.push(`Записано ${(job.written / 1048576).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} МБ`);
return parts.join(" · ");
}
export function preferenceOptions(metadata, preference = {}) {
let videoId = preference.videoId || "best", audioId = preference.audioId || "best";
const quality = preference.quality;
if (videoId !== "best" && quality?.height) {
let candidates = metadata.videos.filter(video => video.height <= quality.height);
if (!candidates.length) candidates = metadata.videos;
candidates = [...candidates].sort((a, b) => b.height - a.height ||
(Number(codecName(b.codec) === quality.codec) - Number(codecName(a.codec) === quality.codec)) ||
Math.abs(a.fps - (quality.fps || 30)) - Math.abs(b.fps - (quality.fps || 30)));
videoId = candidates[0]?.id || "best";
} else if (videoId !== "best" && !metadata.videos.some(video => video.id === videoId)) videoId = "best";
if (audioId !== "best") {
const candidates = preference.audioLanguage ? metadata.audios.filter(audio => audio.language === preference.audioLanguage) : metadata.audios;
audioId = candidates.find(audio => audio.id === audioId)?.id || candidates[0]?.id || "best";
}
const mode = ["video", "video_only", "audio"].includes(preference.mode) ? preference.mode : "video";
return { mode, videoId, audioId, container: mode === "audio" ? preference.audioContainer || "m4a" : preference.videoContainer || "mp4",
codec: preference.codec || "auto", engine: preference.engine || "auto", preset: preference.preset || "balanced",
bitrate: preference.bitrate || 256, folder: preference.folder || "", workFolder: preference.workFolder || "", filename: preference.filename || "" };
}
+18
View File
@@ -0,0 +1,18 @@
:root{color-scheme:dark;--bg:#15181e;--panel:#1c2028;--field:#232833;--text:#eef1f7;--muted:#939eae;--line:#303847;--accent:#46c3ee;--accent-text:#072330;--danger:#ff9797;--shadow:0 12px 34px #0002}
@media(prefers-color-scheme:light){:root:not([data-theme=dark]){color-scheme:light;--bg:#fafbfd;--panel:#fff;--field:#f0f3f8;--text:#182234;--muted:#69768a;--line:#dde4ee;--accent:#087eae;--accent-text:#fff;--danger:#ad3030;--shadow:0 8px 24px #142f5010}}
:root[data-theme=light]{color-scheme:light;--bg:#fafbfd;--panel:#fff;--field:#f0f3f8;--text:#182234;--muted:#69768a;--line:#dde4ee;--accent:#087eae;--accent-text:#fff;--danger:#ad3030;--shadow:0 8px 24px #142f5010}
*{box-sizing:border-box}html,body{margin:0;min-width:300px;background:var(--bg);color:var(--text);font:13px/1.45 "Segoe UI",Arial,sans-serif}body{transition:background .22s,color .22s}.app{padding:20px 22px 14px;max-width:600px;margin:auto;animation:enter .25s ease-out}.header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:24px}.brand{display:flex;align-items:center;gap:11px}.brand-icon{display:grid;place-items:center;width:37px;height:39px;border-radius:12px;background:linear-gradient(150deg,#62d6fa,#178aca);color:#073249;font-size:28px;font-weight:700;box-shadow:0 5px 15px #14a4d52b}.brand strong{font-size:18px;letter-spacing:-.6px}.brand div>span{display:block;font-size:11px;color:var(--muted);margin-top:1px}.header-tools{display:flex;gap:4px}.icon-button{border:0;background:none;color:var(--muted);font-size:22px;cursor:pointer;width:31px;height:31px;border-radius:9px;transition:background .15s,transform .15s}.icon-button:hover{background:var(--field);transform:rotate(-8deg)}.source{margin-bottom:18px}.eyebrow,.section-label{color:var(--muted);font-size:9px;letter-spacing:1.8px;font-weight:700}h1{font-size:19px;line-height:1.35;margin:8px 0 6px;letter-spacing:-.45px;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.muted{color:var(--muted);margin:0;font-size:12px}.mode-switch{border:1px solid var(--line);padding:4px;display:flex;border-radius:12px;background:var(--field);margin:0 0 17px;gap:4px}.mode-switch label{flex:1;cursor:pointer}.mode-switch input{position:absolute;opacity:0;width:1px;height:1px}.mode-switch span{display:block;text-align:center;font-weight:600;border-radius:8px;padding:9px 2px;color:var(--muted);transition:background .2s,box-shadow .2s,color .2s}.mode-switch input:checked+span{background:var(--panel);color:var(--text);box-shadow:0 2px 7px #0002}.mode-switch input:focus-visible+span{outline:2px solid var(--accent)}.field{margin-bottom:13px;min-width:0}label:not(.mode-switch label){display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:5px;font-size:11px;font-weight:600;color:var(--muted)}input,select{font:inherit;width:100%;height:39px;background:var(--field);color:var(--text);border:1px solid var(--line);border-radius:10px;padding:0 11px;outline:none;transition:border-color .18s,box-shadow .18s}input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}input::placeholder{color:var(--muted)}select{font-size:12px;padding-right:22px}input:disabled,select:disabled{opacity:.6;cursor:default}.hint{font-size:10px;color:var(--muted);margin:5px 1px 0}.two-columns{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}.text-button{border:0;background:transparent;color:var(--accent);font-size:10px;cursor:pointer;padding:0}.folder-row,.url-row{display:flex;gap:7px;align-items:center}.folder-row input{min-width:0;font-size:11px}.small-button{flex-shrink:0;height:39px;padding:0 12px;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--text);font-size:11px;cursor:pointer;transition:background .15s,transform .15s}.small-button:hover{background:var(--field);transform:translateY(-1px)}.small-button:disabled{opacity:.5;cursor:wait}.advanced{border-top:1px solid var(--line);margin:3px 0 13px;padding-top:10px}.advanced summary{list-style:none;cursor:pointer;color:var(--muted);display:flex;justify-content:space-between;font-size:11px}.advanced summary::-webkit-details-marker{display:none}.advanced summary span{transition:transform .2s}.advanced[open] summary span{transform:rotate(180deg)}.advanced-body{padding-top:11px;animation:enter .2s ease-out}.optional{font-size:10px;font-weight:400;opacity:.7}.processing-hint{font-size:10px;color:var(--muted);margin:8px 0 13px;min-height:15px;line-height:1.5}.download-button{display:flex;justify-content:center;align-items:center;gap:10px;width:100%;height:46px;border:0;border-radius:12px;background:linear-gradient(120deg,var(--accent),color-mix(in srgb,var(--accent) 78%,#126fe5));color:var(--accent-text);font-size:14px;font-weight:700;cursor:pointer;box-shadow:0 5px 18px color-mix(in srgb,var(--accent) 20%,transparent);transition:filter .18s,box-shadow .18s}.download-button:hover{filter:brightness(1.07);box-shadow:0 7px 24px color-mix(in srgb,var(--accent) 30%,transparent)}.download-button:active{filter:brightness(.96)}.download-button:disabled{opacity:.5;cursor:wait;transform:none}.estimate{font-size:10px;opacity:.75;font-weight:400}.alert{border:1px solid color-mix(in srgb,var(--danger) 35%,transparent);background:color-mix(in srgb,var(--danger) 8%,transparent);padding:11px;border-radius:11px;color:var(--danger);font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere;animation:enter .2s ease-out}.loading{display:flex;align-items:center;gap:9px;padding:28px 0;justify-content:center;color:var(--muted);font-size:12px}.spinner{wiLine truncated
@media(max-width:350px){.app{padding:15px}.two-columns{grid-template-columns:1fr}.brand strong{font-size:16px}.mode-switch span{font-size:11px}.job-title{max-width:190px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
@keyframes spin{to{transform:rotate(360deg)}}@keyframes enter{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}}@keyframes shimmer{from{transform:translateX(-100%)}to{transform:translateX(100%)}}@keyframes indeterminate{0%,100%{transform:translateX(0)}50%{transform:translateX(calc(100% / .35 - 100%))}}
.form-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-items:stretch}.form-actions>button{min-width:0;width:100%;height:46px;border-radius:12px;padding:0 12px;font:600 13px "Segoe UI",Arial,sans-serif}.form-actions>.small-button{transform:none}.dependencies{border-top:1px solid var(--line);margin-top:13px;padding-top:11px}.dependency-heading{display:flex;justify-content:space-between;gap:8px;color:var(--muted);font-size:9px;letter-spacing:.7px;margin-bottom:8px}.dependency-heading>span{letter-spacing:0}.dependency-row{display:flex;align-items:center;gap:8px;padding:7px 0}.dependency-dot{width:6px;height:6px;border-radius:50%;background:#8c97a7;flex-shrink:0}.dependency-row[data-status=installed] .dependency-dot{background:#27b98a}.dependency-row[data-status=missing] .dependency-dot{background:#e67d6e}.dependency-row[data-status=outdated] .dependency-dot{background:#dfa942}.dependency-copy{min-width:0;flex:1}.dependency-label{font-size:11px;display:block}.dependency-version{display:block;font-size:9px;color:var(--muted);overflow-wrap:anywhere}.dependency-row button{font-family:inherit;min-width:61px;text-align:right}.dependency-row button:disabled{opacity:.5;cursor:default}@media(max-width:370px){.form-actions{grid-template-columns:1fr}.form-actions>.small-button{order:2}}
.helper-panel{margin:0 0 15px;padding:9px 11px;border:1px solid var(--line);border-radius:11px;background:var(--panel)}.helper-panel summary{list-style:none;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;font-size:11px}.helper-panel summary::-webkit-details-marker{display:none}.helper-indicator{display:flex;align-items:center;gap:7px}.helper-indicator .status-dot{width:7px;height:7px;margin:0;background:#8c97a7;box-shadow:none}.helper-panel[data-state=running] .status-dot{background:#27b98a;box-shadow:0 0 0 3px #27b98a15}.helper-panel[data-state=checking] .status-dot{background:#dfa942}.helper-panel[data-state=missing] .status-dot,.helper-panel[data-state=error] .status-dot{background:#e67d6e}.helper-body{padding-top:9px}.helper-body>p{color:var(--muted);font-size:10px;margin:0 0 10px;overflow-wrap:anywhere}.helper-chevron{color:var(--muted)}.helper-panel[open] .helper-chevron{transform:rotate(180deg)}.helper-actions{display:flex;flex-wrap:wrap;gap:7px}.helper-actions .small-button{height:32px;display:inline-flex;align-items:center;text-decoration:none;font-family:inherit;padding:0 10px}.danger-button{color:var(--danger)}.helper-body #helper-install-note{margin:9px 0 0}footer{line-height:1.6;letter-spacing:0;font-size:10px}
.session-panel{margin:0 0 18px;padding:11px;border:1px solid var(--line);border-radius:11px;background:var(--panel)}.session-heading{display:flex;gap:8px;align-items:center;font-size:11px;font-weight:600}.session-dot{width:7px;height:7px;border-radius:50%;background:#8c97a7;flex-shrink:0}.session-panel[data-state=connected] .session-dot{background:#27b98a}.session-panel[data-state=signed_out] .session-dot,.session-panel[data-state=permission_required] .session-dot{background:#dfa942}.session-panel[data-state=error] .session-dot{background:#e67d6e}.session-panel .hint{line-height:1.5;margin-top:7px}.session-actions{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-top:9px}.session-actions .small-button{height:32px;max-width:100%;white-space:normal;transform:none}.error-actions{margin-top:8px;margin-bottom:15px}
.dependencies>summary{color:var(--muted);font-size:10px;margin-bottom:4px}.dependencies[open]>#dependencies{animation:enter .2s ease-out}.helper-actions a[aria-disabled=true]{pointer-events:none;opacity:.5}
.history-link{border-top:1px solid var(--line);margin-top:17px;padding-top:14px}.history-link .small-button{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;font-family:inherit;font-size:12px;transform:none}.history-link .small-button span{color:var(--accent);font-size:16px}.helper-body label.auto-update{display:flex;justify-content:flex-start;gap:8px;margin:13px 0 0;font-size:11px;font-weight:400;cursor:pointer}.auto-update input{width:15px;height:15px;padding:0;accent-color:var(--accent)}.helper-body #update-note{margin-top:6px}
.help-button{font-size:17px;font-weight:600}.setup{padding:0;width:min(470px,calc(100% - 24px));max-height:calc(100dvh - 28px);border:1px solid var(--line);border-radius:20px;background:var(--bg);color:var(--text);box-shadow:0 24px 90px #0006;overflow:hidden;font:13px/1.5 "Segoe UI",Arial,sans-serif}.setup[open]{display:flex;flex-direction:column;animation:enter .25s ease-out}.setup::backdrop{background:#080d18a6;backdrop-filter:blur(5px)}.setup-header{display:flex;align-items:center;justify-content:space-between;padding:14px 20px 4px;flex-shrink:0}.setup .icon-button:hover{transform:none}.setup-progress{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;list-style:none;margin:5px 20px 0;padding:0;flex-shrink:0;counter-reset:setup-step}.setup-progress li{counter-increment:setup-step;display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 0;font-size:10px;color:var(--muted);text-align:center;white-space:nowrap;border-bottom:2px solid var(--line);transition:color .2s,border-color .2s}.setup-progress li:before{content:counter(setup-step);display:grid;place-items:center;width:22px;height:22px;border-radius:50%;background:var(--field);font-size:11px}.setup-progress li[data-state=current]{color:var(--accent);border-color:var(--accent)}.setup-progress li[data-state=current]:before{background:var(--accent);color:var(--accent-text)}.setup-progress li[data-state=complete]:before{content:"✓";color:#27b98a}.setup-body{padding:18px 22px 20px;overflow:auto;min-height:0;overscroll-behavior:contain}.setup h2{font-size:21px;line-height:1.3;letter-spacing:-.4px;margin:0 0 12px;outline:none}.setup-body section{animation:enter .2s ease-out}.setup-body p{margin:10px 0;color:var(--muted)}.setup-body strong{color:var(--text)}.setup-body .hint{font-size:11px}.setup-body .alert{color:var(--danger)}.setup-preview{display:flex;align-items:center;justify-content:center;gap:7px;margin:22px 0}.setup-example-download,.setup-example-settings{display:flex;align-items:center;justify-content:center;border:1px solid var(--line);background:var(--field);border-radius:24px;font-weight:600;height:42px;padding:0 19px;color:var(--text)}.setup-example-settings{font-size:20px;width:42px;padding:0}.setup-tip{padding:12px 13px;background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:10px;font-size:12px;margin:15px 0 0;overflow-wrap:anywhere}.setup-instructions{padding-left:21px;color:var(--muted);margin:16px 0}.setup-instructions li{padding-left:3px;margin:10px 0}.setup-helper{display:flex;align-items:center;gap:9px;border:1px solid var(--line);background:var(--panel);border-radius:12px;padding:13px;margin:16px 0 8px}.setup-helper .status-dot{width:7px;height:7px;box-shadow:none;background:#8c97a7;margin:0;flex-shrink:0}.setup-helper[data-state=running] .status-dot{background:#27b98a}.setup-helper[data-state=checking] .status-dot{background:#dfa942}.setup-helper[data-state=missing] .status-dot,.setup-helper[data-state=error] .status-dot{background:#e67d6e}.setup-helper-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:13px}.setup-helper-actions .small-button{display:inline-flex;align-items:center;text-decoration:none;transform:none;font:inherit;font-size:11px}.setup-body label.setup-checkbox{display:flex;justify-content:flex-start;gap:8px;font-size:12px;font-weight:400;margin:17px 0 0;cursor:pointer}.setup-checkbox input{width:16px;height:16px;padding:0;accent-color:var(--accent);flex-shrink:0}.setup-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 20px;background:var(--panel);border-top:1px solid var(--line);flex-shrink:0}.setup-footer .text-button{font-size:12px}.setup-navigation{display:flex;gap:8px;min-width:0}.setup-navigation .download-button{width:auto;height:40px;padding:0 15px;box-shadow:none;font:600 12px "Segoe UI",Arial,sans-serif}.setup-navigation .small-button{height:40px;transform:none}.setup .field{margin-top:15px}.setup .two-columns .field{margin-top:0}.setup .setup-saved{color:var(--text);overflow-wrap:anywhere}@media(max-width:370px){.setup-header{padding:10px 14px 0}.setup-progress{margin:0 14px;gap:4px}.setup-progress li{font-size:9px}.setup-body{padding:16px}.setup h2{font-size:19px}.setup-footer{padding:12px;gap:7px}.setup-navigation{gap:5px}.setup-navigation .download-button{padding:0 10px;font-size:11px}.setup-navigation .small-button{padding:0 9px;font-size:11px}.setup-footer .text-button{font-size:11px}}
.job-transfer{font-size:12px;color:var(--muted);margin-top:9px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
.label,.job-stage{font-variant-numeric:tabular-nums}.version-status{font-size:10px;color:var(--muted);font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.version-status[data-state=incompatible]{color:var(--danger)}.helper-panel[data-state=incompatible] .status-dot,.setup-helper[data-state=incompatible] .status-dot{background:var(--danger)}
+3
View File
@@ -0,0 +1,3 @@
// Apply the page theme before the stylesheet paints the embedded menu.
const initialTheme = new URLSearchParams(location.search).get("theme");
if (["dark", "light"].includes(initialTheme)) document.documentElement.dataset.theme = initialTheme;
+57
View File
@@ -0,0 +1,57 @@
// A menu can outlive its MV3 service worker while the user runs the installer.
// Reopen the UI port on the next command; never replay requests already sent.
export function createUiConnection({ onEvent, onDisconnect, runtime = chrome.runtime }) {
const requests = new Map();
const disconnected = () => new Error("Связь с расширением прервана. Нажмите «Проверить подключение» или «Запустить» для повторного подключения.");
let port = null;
function drop(connection) {
if (port !== connection) return;
port = null;
for (const [id, request] of requests) {
if (request.port !== connection) continue;
requests.delete(id); request.reject(disconnected());
}
onDisconnect?.();
}
function connect() {
if (port) return port;
const connection = runtime.connect({ name: "ytdl-ui" });
port = connection;
connection.onMessage.addListener(message => {
if (port !== connection) return;
if (message.event) { onEvent?.(message); return; }
const request = requests.get(message.id);
if (!request || request.port !== connection) return;
requests.delete(message.id);
message.ok ? request.resolve(message.result) : request.reject(new Error(message.error));
});
connection.onDisconnect.addListener(() => {
// Read lastError in the callback to avoid an unchecked runtime error.
void runtime.lastError?.message;
drop(connection);
});
return connection;
}
function call(action, payload = {}) {
return new Promise((resolve, reject) => {
for (let attempt = 0; attempt < 2; attempt++) {
let connection, id;
try {
connection = connect(); id = crypto.randomUUID();
requests.set(id, { resolve, reject, port: connection });
connection.postMessage({ id, action, payload });
return;
} catch (error) {
if (id) requests.delete(id);
if (connection) drop(connection);
// A synchronous disconnected-port error means this command was not sent.
if (attempt === 0 && /disconnected port/i.test(error.message)) continue;
reject(/disconnected port/i.test(error.message) ? disconnected() : error);
return;
}
}
});
}
return { call };
}
+1
View File
@@ -0,0 +1 @@
"""YouTubeDL native messaging helper."""
+37
View File
@@ -0,0 +1,37 @@
"""Verify and register an unpacked offline bundle. Never downloads anything."""
from __future__ import annotations
import json
import sys
from pathlib import Path
from host.bundle import ROOT, executable, manifest
from host.common import EXTENSION_ID, HOST_NAME
from host.dependencies import status
def verify():
if not manifest():
raise ValueError("Autonomous bundle metadata is missing.")
if Path(sys.executable).resolve() != Path(executable("python")):
raise ValueError("The helper must use its embedded Python.")
measured = status()
if any(item["status"] != "installed" for item in measured):
raise ValueError("Bundled components failed verification: " + json.dumps(measured))
print(json.dumps({"ready": True, "dependencies": measured}))
def configure():
verify()
runtime = ROOT / ".runtime"
runtime.mkdir(exist_ok=True)
python = executable("python")
launcher = runtime / "launch-host.cmd"
launcher.write_bytes((f'@echo off\r\nchcp 65001 >nul\r\nset PYTHONIOENCODING=utf-8\r\nset PYTHONUTF8=1\r\n'
f'set PYTHONDONTWRITEBYTECODE=1\r\n"{python}" -I -X utf8 -B -u "{ROOT / "host" / "main.py"}" %*\r\n').encode("utf-8"))
native = {"name": HOST_NAME, "description": "YouTubeDL autonomous helper", "path": str(launcher),
"type": "stdio", "allowed_origins": [f"chrome-extension://{EXTENSION_ID}/"]}
(runtime / "native-host.json").write_text(json.dumps(native, ensure_ascii=False, indent=2), encoding="utf-8")
if __name__ == "__main__":
configure() if sys.argv[1:] == ["configure"] else verify()
+49
View File
@@ -0,0 +1,49 @@
"""Application-local runtime; a bundle never falls back to tools on PATH."""
from __future__ import annotations
import json
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PRODUCT = "YouTubeDL.Helper"
def manifest():
path = ROOT / "bundle.json"
if not path.is_file():
return None
value = json.loads(path.read_text(encoding="utf-8"))
if value.get("product") != PRODUCT or value.get("mode") != "autonomous":
raise ValueError("Повреждён комплект помощника. Повторите установку.")
return value
def executable(name):
bundle = manifest()
if not bundle:
return None
relative = bundle.get("tools", {}).get(name)
if not isinstance(relative, str):
raise ValueError(f"В комплекте отсутствует {name}. Повторите установку помощника.")
candidate = (ROOT / relative).resolve()
if ROOT not in candidate.parents or not candidate.is_file():
raise ValueError(f"Встроенный {name} повреждён. Повторите установку помощника.")
return str(candidate)
def installation_root():
# The installer puts each complete release under <application>/releases/<id>.
if manifest() and ROOT.parent.name == "releases":
return ROOT.parent.parent
return None
def history_path():
installed = installation_root()
return (installed / "data" if installed else ROOT / ".runtime") / "completed-jobs.json"
def information():
bundle = manifest()
return {"mode": "autonomous" if bundle else "source", "version": bundle.get("version") if bundle else None,
"updateTogether": bool(bundle)}
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import math
import re
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
HOST_NAME = "com.youtubedl.native"
VERSION = "1.3.2"
EXTENSION_ID = "ndnoeiaecgmjnjneekaeapeecmmjheli"
VIDEO_CONTAINERS = {"mp4", "mkv", "webm"}
AUDIO_CONTAINERS = {"m4a", "mp3", "opus", "flac", "wav"}
VIDEO_CODECS = {"auto", "copy", "h264", "hevc", "av1", "vp9"}
ENGINES = {"auto", "nvenc", "amf", "cpu"}
MODES = {"video", "video_only", "audio"}
FORMAT_ID = re.compile(r"^[A-Za-z0-9_.-]{1,80}$")
VIDEO_ID = re.compile(r"^[A-Za-z0-9_-]{11}$")
def youtube_url(value: object) -> str:
if not isinstance(value, str) or len(value) > 2048:
raise ValueError("Нужна ссылка на один ролик YouTube.")
try:
parsed = urlsplit(value.strip())
if parsed.scheme != "https" or parsed.username or parsed.password or parsed.port not in (None, 443):
raise ValueError()
host = (parsed.hostname or "").lower()
parts = parsed.path.strip("/").split("/")
if host == "youtu.be" and len(parts) == 1:
video_id = parts[0]
elif host in {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"}:
if parsed.path == "/watch":
video_id = parse_qs(parsed.query).get("v", [""])[0]
elif len(parts) == 2 and parts[0] in {"shorts", "live", "embed"}:
video_id = parts[1]
else:
raise ValueError()
else:
raise ValueError()
if not VIDEO_ID.fullmatch(video_id):
raise ValueError()
except (ValueError, TypeError):
raise ValueError("Поддерживаются HTTPS-ссылки на видео и Shorts с YouTube.") from None
return f"https://www.youtube.com/watch?v={video_id}"
def format_id(value: object) -> str:
if not isinstance(value, str) or not FORMAT_ID.fullmatch(value):
raise ValueError("Некорректный идентификатор дорожки.")
return value
def number(value, default=0):
return value if isinstance(value, (int, float)) and math.isfinite(value) else default
def video_formats(info: dict) -> list[dict]:
return [f for f in info.get("formats", []) if f.get("vcodec") not in (None, "none")
and number(f.get("height")) > 0 and not f.get("has_drm") and FORMAT_ID.fullmatch(str(f.get("format_id", "")))]
def audio_formats(info: dict) -> list[dict]:
return [f for f in info.get("formats", []) if f.get("vcodec") == "none"
and f.get("acodec") not in (None, "none") and not f.get("has_drm")
and FORMAT_ID.fullmatch(str(f.get("format_id", "")))]
def summarize(info: dict) -> dict:
if info.get("is_live"):
raise ValueError("Дождитесь окончания прямой трансляции. Сейчас поддерживаются готовые ролики.")
videos = []
for f in reversed(video_formats(info)):
videos.append({"id": str(f["format_id"]), "height": number(f.get("height")),
"width": number(f.get("width")), "fps": number(f.get("fps")),
"codec": f.get("vcodec", ""), "extension": f.get("ext", ""),
"hasAudio": f.get("acodec") not in (None, "none"),
"size": number(f.get("filesize") or f.get("filesize_approx")),
"hdr": f.get("dynamic_range", "SDR")})
audios = [{"id": str(f["format_id"]), "codec": f.get("acodec", ""),
"extension": f.get("ext", ""), "bitrate": number(f.get("abr") or f.get("tbr")),
"language": f.get("language") or "", "note": f.get("format_note") or "",
"size": number(f.get("filesize") or f.get("filesize_approx"))}
for f in reversed(audio_formats(info))]
if not videos and not audios:
raise ValueError("YouTube не вернул доступных дорожек для этого ролика.")
return {"id": str(info.get("id", "")), "url": youtube_url(info.get("webpage_url", "")),
"title": str(info.get("title", "Видео"))[:300],
"channel": str(info.get("uploader") or info.get("channel") or "")[:200],
"duration": number(info.get("duration")), "videos": videos[:180], "audios": audios[:100]}
def validate_download(payload: dict) -> dict:
if not isinstance(payload, dict):
raise ValueError("Некорректная заявка.")
mode = payload.get("mode", "video")
container = payload.get("container", "mp4")
codec = payload.get("codec", "auto")
engine = payload.get("engine", "auto")
if mode not in MODES or container not in (AUDIO_CONTAINERS if mode == "audio" else VIDEO_CONTAINERS):
raise ValueError("Выберите корректный тип загрузки и формат файла.")
if codec not in VIDEO_CODECS or engine not in ENGINES or payload.get("preset", "balanced") not in {"fast", "balanced", "quality"}:
raise ValueError("Некорректные настройки кодирования.")
bitrate = payload.get("bitrate", 256)
if isinstance(bitrate, bool) or bitrate not in {128, 192, 256, 320}:
raise ValueError("Некорректный битрейт аудио.")
folder = payload.get("folder")
if not isinstance(folder, str) or not folder or len(folder) > 4096:
raise ValueError("Выберите папку сохранения.")
path = Path(folder).expanduser()
if not path.is_absolute() or not path.is_dir():
raise ValueError("Папка сохранения должна существовать и иметь абсолютный путь.")
work_folder = payload.get("workFolder", "")
if not isinstance(work_folder, str) or len(work_folder) > 4096:
raise ValueError("Выберите корректную рабочую папку.")
work_folder = work_folder or folder
work_path = Path(work_folder).expanduser()
if not work_path.is_absolute() or not work_path.is_dir():
raise ValueError("Рабочая папка должна существовать и иметь абсолютный путь.")
name = payload.get("filename", "")
if not isinstance(name, str) or len(name) > 180:
raise ValueError("Имя файла слишком длинное.")
return {"url": youtube_url(payload.get("url")), "mode": mode, "container": container,
"codec": codec, "engine": engine, "preset": payload.get("preset", "balanced"),
"bitrate": bitrate, "folder": str(path.resolve()), "workFolder": str(work_path.resolve()), "filename": name,
"videoId": format_id(payload.get("videoId", "best")),
"audioId": format_id(payload.get("audioId", "best"))}
def select_streams(info: dict, options: dict) -> str:
videos, audios = video_formats(info), audio_formats(info)
def choose(items, wanted, label):
if not items:
raise ValueError(f"Недоступна дорожка: {label}.")
if wanted == "best":
return items[-1]
for item in items:
if str(item["format_id"]) == wanted:
return item
raise ValueError("Выбранная дорожка исчезла. Обновите список качества.")
if options["mode"] == "audio":
# A progressive video is a valid fallback when separate audio is absent.
return str(choose(audios or [f for f in videos if f.get("acodec") not in (None, "none")],
options["audioId"], "аудио")["format_id"])
video = choose(videos, options["videoId"], "видео")
if options["mode"] == "video_only" or video.get("acodec") not in (None, "none"):
return str(video["format_id"])
audio = choose(audios, options["audioId"], "аудио")
return f"{video['format_id']}+{audio['format_id']}"
def safe_filename(value: str, fallback: str) -> str:
value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", value).strip(" .")[:150].rstrip(" .")
if not value:
value = fallback
if value.split(".", 1)[0].upper() in {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}:
value = "_" + value
return value
+116
View File
@@ -0,0 +1,116 @@
"""Measured dependency status and isolated installers for the helper."""
from __future__ import annotations
import importlib.metadata
import importlib.util
import json
import re
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from host.media import ROOT, NO_WINDOW, config, run_small, tool
from host.bundle import manifest
LABELS = {"python": "Python", "yt_dlp": "yt-dlp", "ejs": "YouTube JS (EJS)", "node": "Node.js", "ffmpeg": "FFmpeg + FFprobe"}
def status():
python_ok = sys.version_info >= (3, 10)
result = [{"id": "python", "label": LABELS["python"], "status": "installed" if python_ok else "missing", "version": sys.version.split()[0]}]
for key, package in (("yt_dlp", "yt-dlp"), ("ejs", "yt-dlp-ejs")):
try:
version = importlib.metadata.version(package)
result.append({"id": key, "label": LABELS[key], "status": "installed", "version": version})
except importlib.metadata.PackageNotFoundError:
result.append({"id": key, "label": LABELS[key], "status": "missing", "version": ""})
for key in ("node", "ffmpeg"):
try:
measured = run_small([tool(key), "--version" if key == "node" else "-version"], timeout=8)
first = measured.stdout.splitlines()[0] if measured.stdout else ""
if measured.returncode != 0 or not first:
raise ValueError(f"Version check failed ({measured.returncode}): {measured.stderr[-200:]}")
version = first if key == "node" else first.split("version ", 1)[-1].split(" ", 1)[0]
installed = int(version.lstrip("v").split(".", 1)[0]) >= 22 if key == "node" else run_small([tool("ffprobe"), "-version"], timeout=8).returncode == 0
result.append({"id": key, "label": LABELS[key], "status": "installed" if installed else "outdated", "version": version})
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
result.append({"id": key, "label": LABELS[key], "status": "missing", "version": "", "message": str(error)[:300]})
return result
def fetch(url, destination, progress):
request = urllib.request.Request(url, headers={"User-Agent": "YouTubeDL-Helper/1.1"})
with urllib.request.urlopen(request, timeout=30) as response, destination.open("wb") as output:
total = int(response.headers.get("Content-Length", 0)); received = 0
while chunk := response.read(1024 * 512):
output.write(chunk); received += len(chunk)
progress(min(75, received / total * 75) if total else None, "Скачиваю компонент")
def extract(archive, folder):
root = folder.resolve()
with zipfile.ZipFile(archive) as bundle:
for entry in bundle.infolist():
target = (root / entry.filename).resolve()
if target != root and root not in target.parents:
raise ValueError("Некорректный путь в архиве компонента.")
bundle.extract(entry, root)
def install(key, progress, workspace=None):
if manifest():
raise ValueError("Нажмите «Обновить компоненты» для установки новых версий из интернета или «Восстановить» для переустановки встроенного комплекта.")
if key not in {"node", "ffmpeg", "yt_dlp", "ejs"}:
raise ValueError("Неизвестный компонент.")
runtime = ROOT / ".runtime"
runtime.mkdir(exist_ok=True)
cfg = config()
with tempfile.TemporaryDirectory(prefix=".ytdl-install-", dir=runtime) as temporary:
scratch = Path(temporary)
if workspace:
workspace(str(scratch))
if key in {"yt_dlp", "ejs"}:
progress(None, "Устанавливаю Python-пакет")
package = "yt-dlp[default]>=2026.8.19" if key == "yt_dlp" else "yt-dlp-ejs"
measured = subprocess.run([sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "--upgrade", package],
stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", errors="replace", creationflags=NO_WINDOW, timeout=600)
if measured.returncode:
raise ValueError("Не удалось установить компонент. " + measured.stderr[-1200:])
else:
archive = scratch / "component.zip"
if key == "node":
with urllib.request.urlopen("https://nodejs.org/dist/index.json", timeout=30) as response:
versions = json.load(response)
release = next(item["version"] for item in versions if item.get("lts") and "win-x64-zip" in item.get("files", []) and re.fullmatch(r"v\d+\.\d+\.\d+", item["version"]) and int(item["version"].split(".")[0][1:]) >= 22)
fetch(f"https://nodejs.org/dist/{release}/node-{release}-win-x64.zip", archive, progress)
destination = runtime / "tools" / "node"
else:
fetch("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", archive, progress)
destination = runtime / "tools" / "ffmpeg"
progress(80, "Распаковываю компонент")
extract(archive, destination)
# Newer versions may coexist; choose the executable from this archive.
with zipfile.ZipFile(archive) as bundle:
relative = next(name for name in bundle.namelist() if name.endswith("/node.exe" if key == "node" else "/bin/ffmpeg.exe"))
executable = destination / relative
cfg[key] = str(executable.resolve())
if key == "ffmpeg":
cfg["ffprobe"] = str(executable.with_name("ffprobe.exe").resolve())
progress(95, "Проверяю компонент")
runtime.joinpath("config.json").write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
measured = next(item for item in status() if item["id"] == key)
if measured["status"] != "installed":
raise ValueError("Компонент установлен, но проверка запуска не прошла.")
progress(100, "Установка завершена")
return {"title": LABELS[key], "dependency": key, "version": measured["version"]}
def main():
key = sys.argv[1]
result = install(key, lambda percent, message: print(f"{message}: {percent if percent is not None else '…'}%", flush=True))
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__": main()
+71
View File
@@ -0,0 +1,71 @@
"""Read FFmpeg's real timeline and output size without blocking stderr."""
from __future__ import annotations
import math
import subprocess
import threading
from host.media import NO_WINDOW
def run_ffmpeg(command, duration, stage, engine, report):
duration = float(duration or 0)
if not math.isfinite(duration) or duration < 0:
duration = 0
report(stage=stage, percent=0 if duration else None, engine=engine,
speed="", eta=None, processedSeconds=0, durationSeconds=duration, written=0, message="")
process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", creationflags=NO_WINDOW)
errors, finalizing = [], threading.Event()
progress_lock = threading.Lock()
def read_errors():
for line in process.stderr:
errors.append(line)
if len(errors) > 80:
del errors[:40]
if "Starting second pass" in line:
with progress_lock:
finalizing.set()
report(stage="finalizing", percent=99, engine="copy", speed="", eta=None,
message="Финализация файла")
reader = threading.Thread(target=read_errors, daemon=True)
reader.start()
stats = {}
elapsed = 0.0
try:
for raw in process.stdout:
key, _, value = raw.strip().partition("=")
stats[key] = value
if key != "progress":
continue
try:
elapsed = max(elapsed, max(0, int(stats.get("out_time_us", "0")) / 1_000_000))
except ValueError:
pass
speed = stats.get("speed", "")
try:
rate = float(speed.rstrip("x"))
except ValueError:
rate = 0
eta = max(0, (duration - elapsed) / rate) if duration and rate > 0 and math.isfinite(rate) and not finalizing.is_set() else None
try:
written = max(0, int(stats.get("total_size", "0")))
except ValueError:
written = None
with progress_lock:
is_finalizing = finalizing.is_set()
report(stage="finalizing" if is_finalizing else stage, percent=99 if is_finalizing else min(99, elapsed / duration * 100) if duration else None,
engine="copy" if is_finalizing else engine, speed=speed if rate > 0 and math.isfinite(rate) and not is_finalizing else "", eta=None if is_finalizing else eta,
processedSeconds=min(elapsed, duration) if duration else elapsed,
durationSeconds=duration, written=written,
message="Финализация файла" if is_finalizing else "")
process.wait()
reader.join(timeout=3)
return process.returncode, "".join(errors)[-2000:]
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=10)
for stream in (process.stdout, process.stderr):
stream.close()
+56
View File
@@ -0,0 +1,56 @@
"""Use the Windows folder picker without shipping Tcl/Tk."""
from __future__ import annotations
import base64
import json
import os
import subprocess
def pick_folder(current):
from host.bundle import installation_root
installed = installation_root()
if installed:
encoded = base64.b64encode(json.dumps(current or "").encode("utf-8")).decode("ascii")
result = subprocess.run([str(installed / "YouTubeDL-Manager.exe"), "--pick-folder", "--initial-folder", encoded],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=600,
creationflags=subprocess.CREATE_NO_WINDOW)
if result.returncode:
raise ValueError("Не удалось открыть окно выбора папки Windows.")
return json.loads(result.stdout.strip().lstrip("\ufeff"))
if os.name != "nt":
import tkinter
from tkinter import filedialog
root = tkinter.Tk()
try:
root.withdraw(); root.attributes("-topmost", True)
return filedialog.askdirectory(initialdir=current or "", mustexist=True) or None
finally:
root.destroy()
# Only base64 data is substituted; paths are never interpolated into shell code.
encoded = base64.b64encode(json.dumps(current or "").encode("utf-8")).decode("ascii")
script = """
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new()
Add-Type -AssemblyName System.Windows.Forms
$taskDialog=[Windows.Forms.FolderBrowserDialog]::new()
$taskOwner=[Windows.Forms.Form]::new()
try {
$taskOwner.TopMost=$true; $taskOwner.ShowInTaskbar=$false; $taskOwner.Opacity=0
$taskDialog.Description='YouTubeDL — папка сохранения'
$taskDialog.ShowNewFolderButton=$true
$taskDialog.SelectedPath=([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('ENCODED')) | ConvertFrom-Json)
$taskOwner.Show()
$taskChosen=$null
if($taskDialog.ShowDialog($taskOwner) -eq [Windows.Forms.DialogResult]::OK){$taskChosen=$taskDialog.SelectedPath}
ConvertTo-Json -InputObject $taskChosen -Compress
} finally {$taskDialog.Dispose();$taskOwner.Dispose()}
""".replace("ENCODED", encoded)
argument = base64.b64encode(script.encode("utf-16le")).decode("ascii")
powershell = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
result = subprocess.run([powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-STA", "-EncodedCommand", argument],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=600,
creationflags=subprocess.CREATE_NO_WINDOW,
env={**os.environ, "PSModulePath": os.path.join(os.path.dirname(powershell), "Modules")})
if result.returncode:
raise ValueError("Не удалось открыть окно выбора папки Windows.")
return json.loads(result.stdout.strip().lstrip("\ufeff"))
+20
View File
@@ -0,0 +1,20 @@
"""Manage only this helper's per-user browser registrations."""
from pathlib import Path
from host.common import HOST_NAME
def unregister(root: Path):
import winreg
expected = str((root / ".runtime" / "native-host.json").resolve()).casefold()
removed = 0
for browser in (r"Google\Chrome", r"Microsoft\Edge", "Chromium"):
key = rf"Software\{browser}\NativeMessagingHosts\{HOST_NAME}"
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key) as handle:
current = winreg.QueryValueEx(handle, "")[0]
if isinstance(current, str) and str(Path(current).resolve()).casefold() == expected:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, key)
removed += 1
except FileNotFoundError:
continue
return {"removed": True, "registrations": removed}
+399
View File
@@ -0,0 +1,399 @@
"""Chrome/Edge native messaging host; no listening network port."""
from __future__ import annotations
import concurrent.futures
import json
import os
import shutil
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from host.common import EXTENSION_ID, VERSION, validate_download, youtube_url
from host.media import NO_WINDOW, ROOT, capabilities
from host.protocol import read_message, write_message
from host.session import validate_cookies
from host.bundle import history_path, installation_root
from host.processes import ProcessTree
class NativeHost:
def __init__(self, output):
self.output = output
self.output_lock = threading.Lock()
self.state_lock = threading.RLock()
self.capability_lock = threading.Lock()
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=4)
self.history_path = history_path() if installation_root() else ROOT / ".runtime" / "completed-jobs.json"
self.jobs = self.read_history()
self.processes = {}
self.closed = False
self._capabilities = None
self.picker_lock = threading.Lock()
self.auto_timer = None
self.auto_candidates = None
def read_history(self):
try:
history = json.loads(self.history_path.read_text(encoding="utf-8"))
if not isinstance(history, list):
return {}
return {job["id"]: job for job in history[:30] if isinstance(job, dict)
and isinstance(job.get("id"), str) and job.get("status") == "complete"
and isinstance(job.get("result"), dict) and isinstance(job["result"].get("path"), str)
and Path(job["result"]["path"]).is_absolute()}
except (OSError, ValueError):
return {}
def save_history(self):
# Keep completed file references so Reveal still works after a browser restart.
history = sorted((dict(job) for job in self.jobs.values() if job["status"] == "complete" and job.get("result", {}).get("path")),
key=lambda job: job.get("createdAt", 0), reverse=True)[:30]
temporary = self.history_path.with_suffix(".tmp")
try:
self.history_path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text(json.dumps(history, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, self.history_path)
except OSError:
pass # Saving a history entry must not fail an otherwise completed download.
def send(self, message):
with self.output_lock:
if not self.closed:
try:
write_message(self.output, message)
except (BrokenPipeError, OSError):
self.closed = True
def reply(self, request_id, result=None, error=None):
self.send({"id": request_id, "ok": error is None, "result": result, "error": error})
def hardware(self):
with self.capability_lock:
if self._capabilities is None:
self._capabilities = capabilities()
return self._capabilities
def event(self, job_id, **changes):
with self.state_lock:
state = self.jobs[job_id]
if state.get("status") in {"cancelled", "complete", "error"} and changes.get("status") != state.get("status"):
return
if state.get("cancelling") and changes.get("status") == "complete":
return
if state.get("cancelling") and changes.get("status") is None and changes.get("stage") != "cancelling":
return
state.update(changes)
state["updatedAt"] = time.time()
if changes.get("status") == "complete":
self.save_history()
snapshot = dict(state)
self.send({"event": "job", "job": snapshot})
def kill(self, process):
tree = getattr(process, "ytdl_process_tree", None)
if isinstance(tree, ProcessTree):
tree.terminate()
return
if process.poll() is not None:
return
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, creationflags=NO_WINDOW, timeout=15)
else:
import signal
os.killpg(process.pid, signal.SIGTERM)
def worker(self, action, payload, job_id=None):
hardware = self.hardware() if action == "download" else {}
encoders = {key: hardware.get(key, []) for key in ("nvenc", "amf", "amf10bit")}
# CREATE_SUSPENDED + job assignment happen before even the Python
# launcher can create children. stdin is a second gate for the task.
process = ProcessTree.launch([sys.executable, "-X", "utf8", "-B", "-u", str(ROOT / "host" / "worker.py")],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", creationflags=NO_WINDOW,
start_new_session=os.name != "nt")
token = job_id or str(uuid.uuid4())
with self.state_lock:
self.processes[token] = process
cancelled = self.closed or (job_id and (self.jobs[job_id]["status"] == "cancelled" or self.jobs[job_id].get("cancelling")))
if cancelled:
self.kill(process)
process.ytdl_process_tree.close()
with self.state_lock:
self.processes.pop(token, None)
return None
try:
process.stdin.write(json.dumps({"action": action, "payload": payload, "encoders": encoders}) + "\n")
process.stdin.close()
except BaseException:
self.kill(process)
process.ytdl_process_tree.close()
with self.state_lock:
self.processes.pop(token, None)
raise
errors = []
def stderr_reader():
for line in process.stderr:
errors.append(line)
if len(errors) > 40:
del errors[:20]
reader = threading.Thread(target=stderr_reader, daemon=True)
reader.start()
result, error, workspaces = None, None, []
workspace_parent = Path(payload.get("workFolder") or payload["folder"]).resolve() if action == "download" else (ROOT / ".runtime").resolve()
if action == "helper_update": workspace_parent = (installation_root() / "releases").resolve()
workspace_parents = {workspace_parent}
if action == "download": workspace_parents.add(Path(payload["folder"]).resolve())
try:
for line in process.stdout:
message = json.loads(line)
if message["type"] == "progress" and job_id:
self.event(job_id, **{k: v for k, v in message.items() if k != "type"})
elif message["type"] == "result":
result = message["result"]
elif message["type"] == "error":
error = message["message"]
elif message["type"] == "workspace" and action in {"download", "dependency_install", "helper_update"}:
candidate = Path(message["path"])
if candidate.parent.resolve() in workspace_parents and candidate.name.startswith(".ytdl-") and not candidate.is_symlink():
if candidate not in workspaces: workspaces.append(candidate)
process.wait()
reader.join(timeout=2)
if job_id and (self.jobs[job_id]["status"] == "cancelled" or self.jobs[job_id].get("cancelling")):
return None
if error or process.returncode or result is None:
raise ValueError(error or "Локальный помощник завершился с ошибкой. " + "".join(errors)[-1000:])
return result
finally:
if process.poll() is None:
self.kill(process)
process.ytdl_process_tree.close()
for stream in (process.stdin, process.stdout, process.stderr):
stream.close()
with self.state_lock:
self.processes.pop(token, None)
for workspace in workspaces:
if not workspace.exists() or workspace.is_symlink() or workspace.resolve().parent not in workspace_parents:
continue
retained = False
if action == "helper_update":
marker = json.loads((installation_root() / "installation.json").read_text(encoding="utf-8-sig"))
retained = Path(marker["current"]).resolve() == workspace.resolve()
if not retained: shutil.rmtree(workspace)
def run_job(self, job_id, payload, action="download"):
try:
try:
result = self.worker(action, payload, job_id)
finally:
if action in {"dependency_install", "helper_update"}:
with self.capability_lock:
self._capabilities = None
if result is not None:
self.event(job_id, status="complete", stage="complete", percent=100, result=result)
except Exception as error:
with self.state_lock:
cancelled = self.jobs[job_id]["status"] == "cancelled" or self.jobs[job_id].get("cancelling")
if not cancelled:
self.event(job_id, status="error", stage="error", error=str(error)[:2000])
def start_update(self, candidates=None):
if not installation_root(): raise ValueError("Для обновления нужен автономный помощник.")
with self.state_lock:
if self.processes or any(job["status"] == "running" for job in self.jobs.values()):
raise ValueError("Дождитесь завершения текущих операций перед обновлением.")
job_id = str(uuid.uuid4())
self.jobs[job_id] = {"id": job_id, "status": "running", "stage": "updating", "kind": "update", "title": "Обновление компонентов",
"percent": None, "createdAt": time.time(), "canCancel": False}
self.event(job_id)
self.executor.submit(self.run_job, job_id, {"candidates": candidates}, "helper_update")
return {"jobId": job_id}
def schedule_updates(self, delay=30):
if self.closed or not installation_root() or self.auto_timer is not None: return
self.auto_timer = threading.Timer(delay, self.auto_check)
self.auto_timer.daemon = True; self.auto_timer.start()
def auto_check(self):
from host.updates import check_updates, due, settings
try:
with self.state_lock:
if self.closed or self.processes or self._capabilities and self._capabilities.get("ready") is False or any(job["status"] == "running" for job in self.jobs.values()): return
if not settings()["enabled"]: self.auto_candidates = None; return
if self.auto_candidates is None:
if not due(): return
self.auto_candidates = check_updates()
settings(lastCheck=time.time())
with self.state_lock:
if self.closed or self.processes or any(job["status"] == "running" for job in self.jobs.values()): return
if self.auto_candidates: self.start_update(self.auto_candidates)
self.auto_candidates = None
except Exception:
# Quietly retry a failed automatic metadata check in an hour.
try: settings(lastCheck=time.time() - 86400 + 3600)
except OSError: pass
finally:
self.auto_timer = None
self.schedule_updates(60)
def pick_folder(self, current):
from host.folders import pick_folder
if not self.picker_lock.acquire(blocking=False):
raise ValueError("Диалог выбора папки уже открыт.")
try:
initial = current if isinstance(current, str) and Path(current).is_dir() else str(Path.home())
folder = pick_folder(initial)
return {"folder": folder or None}
finally:
self.picker_lock.release()
def handle(self, request):
request_id = request.get("id")
if not isinstance(request_id, str) or len(request_id) > 100:
return
action, payload = request.get("action"), request.get("payload", {})
try:
if not isinstance(payload, dict):
raise ValueError("Некорректное сообщение.")
if action == "hello":
from host.updates import settings
result = {**self.hardware(), "supportsBrowserCookies": True, "supportsComponentUpdates": bool(installation_root()), "updateSettings": settings()}
self.schedule_updates()
elif action == "ping":
result = {"alive": True, "pid": os.getpid(), "version": VERSION}
elif action == "update_settings":
from host.updates import settings
if not isinstance(payload.get("enabled"), bool): raise ValueError("Некорректная настройка обновлений.")
result = settings(payload["enabled"])
elif action == "helper_update":
result = self.start_update()
elif action == "helper_uninstall":
with self.state_lock:
if any(job["status"] == "running" for job in self.jobs.values()):
raise ValueError("Дождитесь завершения загрузок перед удалением помощника.")
from host.installation import unregister
result = unregister(ROOT)
installed = installation_root()
if installed and (installed / "YouTubeDL-Manager.exe").is_file():
# The uninstaller outlives the native port. Its output must
# never inherit the browser's native-messaging pipes.
log = installed / "data" / "uninstall.log"
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("wb") as output:
subprocess.Popen([str(installed / "YouTubeDL-Manager.exe"), "--uninstall", "--silent", "--wait-pid", str(os.getpid())],
stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT,
close_fds=True, creationflags=NO_WINDOW)
result["uninstallStarted"] = True
elif action == "inspect":
result = self.worker("inspect", {"url": youtube_url(payload.get("url")), "browserCookies": validate_cookies(payload.get("browserCookies"))})
elif action == "pick_folder":
result = self.pick_folder(payload.get("current"))
elif action == "download":
payload = {**validate_download(payload), "browserCookies": validate_cookies(payload.get("browserCookies"))}
with self.state_lock:
if any(j["status"] == "running" and j.get("kind") in {"dependency", "update"} for j in self.jobs.values()):
raise ValueError("Дождитесь завершения установки компонентов.")
if sum(j["status"] == "running" for j in self.jobs.values()) >= 2:
raise ValueError("Уже выполняются две загрузки. Дождитесь завершения одной.")
job_id = str(uuid.uuid4())
self.jobs[job_id] = {"id": job_id, "url": payload["url"], "status": "running",
"stage": "preparing", "percent": None, "createdAt": time.time()}
self.reply(request_id, {"jobId": job_id})
self.executor.submit(self.run_job, job_id, payload)
return
elif action == "dependency_install":
from host.dependencies import LABELS
key = payload.get("dependency")
if key not in {"node", "ffmpeg", "yt_dlp", "ejs"}:
raise ValueError("Неизвестный компонент.")
with self.state_lock:
if any(job["status"] == "running" for job in self.jobs.values()):
raise ValueError("Дождитесь завершения текущих операций перед установкой компонентов.")
job_id = str(uuid.uuid4())
self.jobs[job_id] = {"id": job_id, "status": "running", "stage": "installing", "kind": "dependency",
"title": LABELS[key], "percent": None, "createdAt": time.time()}
self.reply(request_id, {"jobId": job_id})
self.executor.submit(self.run_job, job_id, {"dependency": key}, action)
return
elif action == "jobs":
with self.state_lock:
result = [dict(job) for job in self.jobs.values()]
elif action == "cancel":
job_id = payload.get("jobId")
with self.state_lock:
job = self.jobs.get(job_id)
if not job or job["status"] != "running":
raise ValueError("Эта загрузка уже завершена.")
if job.get("canCancel") is False:
raise ValueError("Дождитесь завершения обновления компонентов.")
self.event(job_id, stage="cancelling", percent=None, cancelling=True, canCancel=False,
speed="", eta=None, message="Останавливаю процессы загрузки и обработки")
process = self.processes.get(job_id)
if process:
try:
self.kill(process)
except Exception as error:
self.event(job_id, status="error", stage="error", cancelling=False,
error="Не удалось остановить процессы: " + str(error))
raise
self.event(job_id, status="cancelled", stage="cancelled", percent=None, cancelling=False)
result = {"cancelled": True}
elif action == "reveal":
with self.state_lock:
job = self.jobs.get(payload.get("jobId"))
if not job or job["status"] != "complete":
raise ValueError("Файл ещё не готов.")
path = Path(job["result"]["path"])
if not path.is_file():
raise ValueError("Файл был перемещён или удалён.")
if os.name == "nt":
subprocess.Popen(["explorer.exe", "/select,", str(path)], creationflags=NO_WINDOW)
result = {"shown": True}
else:
raise ValueError("Неизвестная операция.")
self.reply(request_id, result)
except Exception as error:
self.reply(request_id, error=str(error)[:2000])
def close(self):
self.closed = True
if self.auto_timer: self.auto_timer.cancel()
with self.state_lock:
processes = list(self.processes.values())
for process in processes:
try:
self.kill(process)
except (OSError, subprocess.TimeoutExpired):
pass
self.executor.shutdown(wait=True, cancel_futures=True)
def main():
if len(sys.argv) > 1 and sys.argv[1] != f"chrome-extension://{EXTENSION_ID}/":
print("Недопустимый источник native messaging.", file=sys.stderr)
return 1
if os.name == "nt":
import msvcrt
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
host = NativeHost(sys.stdout.buffer)
try:
while (request := read_message(sys.stdin.buffer)) is not None:
if request.get("action") in {"cancel", "ping"}:
host.handle(request)
else:
host.executor.submit(host.handle, request)
except (EOFError, ValueError, OSError) as error:
print(str(error), file=sys.stderr)
finally:
host.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
from host.common import VERSION
from host.bundle import executable, information
ROOT = Path(__file__).resolve().parent.parent
NO_WINDOW = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
def config():
path = ROOT / ".runtime" / "config.json"
return json.loads(path.read_text(encoding="utf-8-sig")) if path.is_file() else {}
def tool(name):
bundled = executable(name)
if bundled:
return bundled
configured = config().get(name)
result = configured if configured and Path(configured).is_file() else shutil.which(name)
if not result or not Path(result).is_file():
raise ValueError(f"Не найден {name}. Запустите install.ps1.")
return str(Path(result).resolve())
def run_small(command, timeout=15):
return subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, creationflags=NO_WINDOW, stdin=subprocess.DEVNULL)
def capabilities():
from host.dependencies import status
dependencies = status()
ready = all(item["status"] == "installed" for item in dependencies)
ffmpeg = next((item for item in dependencies if item["id"] == "ffmpeg" and item["status"] == "installed"), None)
checked = {"nvenc": [], "amf": [], "amf10bit": []}
for engine in ("nvenc", "amf"):
for codec in ("h264", "hevc", "av1"):
if not ffmpeg:
break
base = [tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i"]
preset = ["-preset", "p4"] if engine == "nvenc" else ["-quality", "balanced", "-rc", "cqp", "-qp_i", "20", "-qp_p", "20"]
try:
result = run_small([*base, "color=c=black:s=640x360:r=30", "-frames:v", "2", "-c:v", f"{codec}_{engine}", *preset, "-f", "null", "-"], timeout=20)
if result.returncode == 0:
checked[engine].append(codec)
if engine == "amf" and codec in {"hevc", "av1"}:
extra = ["-profile:v", "main10"] if codec == "hevc" else []
tenbit = run_small([*base, "color=c=black:s=640x360:r=30,format=p010le", "-frames:v", "2", "-c:v", f"{codec}_amf",
*preset, "-bitdepth", "10", *extra, "-pix_fmt", "+p010le", "-f", "null", "-"], timeout=20)
if tenbit.returncode == 0:
checked["amf10bit"].append(codec)
except (OSError, subprocess.TimeoutExpired):
continue
gpu = ""
smi = shutil.which("nvidia-smi")
if smi:
try:
result = run_small([smi, "--query-gpu=name", "--format=csv,noheader"], timeout=8)
gpu = result.stdout.strip()[:200] if result.returncode == 0 else ""
except (OSError, subprocess.TimeoutExpired):
pass
if os.name == "nt":
try:
detected = run_small(["powershell.exe", "-NoProfile", "-Command",
"[Console]::OutputEncoding=[Text.UTF8Encoding]::new(); "
"Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name"], timeout=10)
if detected.returncode == 0:
gpu = detected.stdout.strip()[:200] or gpu
except (OSError, subprocess.TimeoutExpired):
pass
return {"version": VERSION, "minimumExtensionVersion": "1.4.5", "supportsWorkFolder": True, "ytDlp": next(item["version"] for item in dependencies if item["id"] == "yt_dlp"), "ffmpeg": ffmpeg["version"] if ffmpeg else "",
"gpu": gpu, **checked, "defaultFolder": str(Path.home() / "Downloads"),
"dependencies": dependencies, "ready": ready, "bundle": information()}
def probe(path):
result = run_small([tool("ffprobe"), "-v", "error", "-show_streams", "-show_format", "-of", "json", str(path)], timeout=30)
if result.returncode:
raise ValueError("FFprobe не смог прочитать загруженный файл.")
return json.loads(result.stdout)
def encoding_plan(options, media, encoders):
# Older development callers supply the NVENC codec list directly.
encoders = {"nvenc": encoders} if isinstance(encoders, (list, tuple)) else encoders
streams = media.get("streams", [])
video = next((s for s in streams if s.get("codec_type") == "video"), None)
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
mode, container = options["mode"], options["container"]
if mode != "audio" and not video:
raise ValueError("В загруженном файле нет видео.")
if mode in {"audio", "video"} and not audio:
raise ValueError("В загруженном файле нет звуковой дорожки.")
if mode == "audio":
target = {"m4a": "aac", "mp3": "libmp3lame", "opus": "libopus", "flac": "flac", "wav": "pcm_s16le"}[container]
source_audio = audio.get("codec_name")
if (container, source_audio) in {("m4a", "aac"), ("mp3", "mp3"), ("opus", "opus"), ("flac", "flac")}:
target = "copy"
return {"video": None, "audio": target, "engine": "copy" if target == "copy" else "cpu", "hdr": False}
source = video.get("codec_name", "")
compatible = container == "mkv" or (container == "mp4" and source in {"h264", "hevc", "av1"}) or (container == "webm" and source in {"vp8", "vp9", "av1"})
codec = options["codec"]
hdr = video.get("color_transfer") in {"smpte2084", "arib-std-b67"}
ten_bit = "10" in video.get("pix_fmt", "") or video.get("pix_fmt", "").startswith("p010")
if codec == "copy" and not compatible:
raise ValueError("Исходный видеокодек несовместим с контейнером. Выберите «Авто» или MKV.")
if codec == "auto":
codec = "copy" if compatible else ("av1" if container == "webm" else ("hevc" if hdr else "h264"))
if container == "webm" and codec not in {"copy", "vp9", "av1"}:
raise ValueError("WebM поддерживает AV1 и VP9. Для H.264/HEVC выберите MP4 или MKV.")
if hdr and codec == "h264":
raise ValueError("Для HDR выберите HEVC, AV1 или сохранение исходного кодека.")
engine = "copy" if codec == "copy" else "cpu"
if codec != "copy" and options["engine"] != "cpu":
for candidate in ("nvenc", "amf"):
supported = encoders.get("amf10bit" if candidate == "amf" and ten_bit and codec in {"hevc", "av1"} else candidate, [])
if options["engine"] in {"auto", candidate} and codec in supported:
engine = candidate
break
if options["engine"] in {"nvenc", "amf"} and engine != options["engine"]:
name = "AMD AMF" if options["engine"] == "amf" else "NVIDIA NVENC"
raise ValueError(f"Выбранный кодек или глубина цвета недоступны в {name}. Выберите поддерживаемый кодек или «Авто».")
audio_codec = None
if mode == "video":
ac = audio.get("codec_name", "")
if container == "mkv" or (container == "mp4" and ac in {"aac", "mp3", "alac"}) or (container == "webm" and ac in {"opus", "vorbis"}):
audio_codec = "copy"
else:
audio_codec = "libopus" if container == "webm" else "aac"
if engine == "copy" and audio_codec not in (None, "copy"):
engine = "cpu"
return {"video": codec, "audio": audio_codec, "engine": engine, "hdr": hdr, "tenBit": ten_bit}
def ffmpeg_command(source, target, options, plan, cuda_decode=True):
command = [tool("ffmpeg"), "-hide_banner", "-nostdin", "-loglevel", "info", "-y", "-progress", "pipe:1", "-stats_period", "0.4"]
if plan["engine"] == "nvenc" and cuda_decode:
command += ["-hwaccel", "cuda"]
elif plan["engine"] == "amf" and cuda_decode:
command += ["-hwaccel", "d3d11va"]
command += ["-i", str(source)]
if options["mode"] == "audio":
command += ["-map", "0:a:0", "-vn"]
else:
command += ["-map", "0:v:0"]
if options["mode"] == "video":
command += ["-map", "0:a:0"]
else:
command += ["-an"]
codec = plan["video"]
if codec:
if codec == "copy":
command += ["-c:v", "copy"]
elif plan["engine"] == "nvenc":
preset = {"fast": "p2", "balanced": "p4", "quality": "p6"}[options["preset"]]
command += ["-c:v", codec + "_nvenc", "-preset", preset, "-rc", "vbr", "-cq", "20", "-b:v", "0"]
command += ["-pix_fmt", "p010le" if plan.get("tenBit") and codec in {"hevc", "av1"} else "yuv420p"]
elif plan["engine"] == "amf":
quality = {"fast": "speed", "balanced": "balanced", "quality": "quality"}[options["preset"]]
command += ["-c:v", codec + "_amf", "-quality", quality, "-rc", "cqp", "-qp_i", "20", "-qp_p", "20"]
if plan.get("tenBit") and codec in {"hevc", "av1"}:
command += ["-bitdepth", "10", "-pix_fmt", "p010le"]
if codec == "hevc": command += ["-profile:v", "main10"]
else:
command += ["-pix_fmt", "yuv420p"]
else:
encoder = {"h264": "libx264", "hevc": "libx265", "av1": "libsvtav1", "vp9": "libvpx-vp9"}[codec]
command += ["-c:v", encoder]
if codec in {"h264", "hevc"}:
command += ["-preset", {"fast": "veryfast", "balanced": "fast", "quality": "medium"}[options["preset"]], "-crf", "20"]
elif codec == "av1":
command += ["-preset", "8", "-crf", "24"]
else:
command += ["-crf", "28", "-b:v", "0", "-row-mt", "1", "-cpu-used", "4"]
command += ["-pix_fmt", "yuv420p10le" if plan.get("tenBit") and codec != "h264" else "yuv420p"]
if plan["audio"]:
command += ["-c:a", plan["audio"]]
if plan["audio"] in {"aac", "libmp3lame", "libopus"}:
command += ["-b:a", f"{options['bitrate']}k"]
command += ["-map_metadata", "0"]
if options["container"] in {"mp4", "m4a"}:
command += ["-movflags", "+faststart"]
if codec == "hevc" and options["container"] == "mp4":
command += ["-tag:v", "hvc1"]
command += [str(target)]
return command
+172
View File
@@ -0,0 +1,172 @@
"""Own one worker's process tree, including children left after its exit."""
from __future__ import annotations
import os
import subprocess
import threading
import time
class ProcessTree:
@classmethod
def launch(cls, command, **kwargs):
if os.name == "nt":
kwargs["creationflags"] = kwargs.get("creationflags", 0) | 0x4 # CREATE_SUSPENDED
else:
kwargs["start_new_session"] = True
process = subprocess.Popen(command, **kwargs)
try:
tree = cls(process)
process.ytdl_process_tree = tree
if os.name == "nt": tree.resume()
return process
except BaseException:
tree = getattr(process, "ytdl_process_tree", None)
if tree: tree.close()
if process.poll() is None: process.kill()
process.wait(timeout=10)
for stream in (process.stdin, process.stdout, process.stderr):
if stream: stream.close()
raise
def __init__(self, process):
self.process = process
self.lock = threading.Lock()
self.handle = None
if os.name != "nt":
return
import ctypes
from ctypes import wintypes
class BasicLimits(ctypes.Structure):
_fields_ = [("ProcessTime", ctypes.c_longlong), ("JobTime", ctypes.c_longlong),
("Flags", wintypes.DWORD), ("MinWorkingSet", ctypes.c_size_t),
("MaxWorkingSet", ctypes.c_size_t), ("ActiveLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t), ("Priority", wintypes.DWORD), ("Scheduling", wintypes.DWORD)]
class Limits(ctypes.Structure):
_fields_ = [("Basic", BasicLimits), ("Io", ctypes.c_ulonglong * 6),
("ProcessMemory", ctypes.c_size_t), ("JobMemory", ctypes.c_size_t),
("PeakProcessMemory", ctypes.c_size_t), ("PeakJobMemory", ctypes.c_size_t)]
class Accounting(ctypes.Structure):
_fields_ = [("Times", ctypes.c_longlong * 4), ("PageFaults", wintypes.DWORD),
("TotalProcesses", wintypes.DWORD), ("ActiveProcesses", wintypes.DWORD),
("TerminatedProcesses", wintypes.DWORD)]
self.ctypes, self.Accounting = ctypes, Accounting
self.api = ctypes.WinDLL("kernel32", use_last_error=True)
for name, args, result in [
("CreateJobObjectW", [ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE),
("SetInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL),
("QueryInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p], wintypes.BOOL),
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
("CreateToolhelp32Snapshot", [wintypes.DWORD, wintypes.DWORD], wintypes.HANDLE),
("Thread32First", [wintypes.HANDLE, ctypes.c_void_p], wintypes.BOOL),
("Thread32Next", [wintypes.HANDLE, ctypes.c_void_p], wintypes.BOOL),
("OpenThread", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
("ResumeThread", [wintypes.HANDLE], wintypes.DWORD),
("WaitForSingleObject", [wintypes.HANDLE, wintypes.DWORD], wintypes.DWORD),
]:
function = getattr(self.api, name)
function.argtypes, function.restype = args, result
handle = self.api.CreateJobObjectW(None, None)
if not handle:
raise ctypes.WinError(ctypes.get_last_error())
try:
limits = Limits()
limits.Basic.Flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if not self.api.SetInformationJobObject(handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)):
raise ctypes.WinError(ctypes.get_last_error())
process_handle = self.api.OpenProcess(0x100 | 0x1, False, process.pid)
if not process_handle:
raise ctypes.WinError(ctypes.get_last_error())
try:
if not self.api.AssignProcessToJobObject(handle, process_handle):
raise ctypes.WinError(ctypes.get_last_error())
finally:
self.api.CloseHandle(process_handle)
self.handle = handle
except BaseException:
self.api.CloseHandle(handle)
raise
def resume(self):
from ctypes import wintypes
ctypes = self.ctypes
class ThreadEntry(ctypes.Structure):
_fields_ = [("Size", wintypes.DWORD), ("Usage", wintypes.DWORD), ("Id", wintypes.DWORD),
("Owner", wintypes.DWORD), ("BasePriority", wintypes.LONG),
("DeltaPriority", wintypes.LONG), ("Flags", wintypes.DWORD)]
snapshot = self.api.CreateToolhelp32Snapshot(0x4, 0) # TH32CS_SNAPTHREAD
if snapshot == ctypes.c_void_p(-1).value:
raise ctypes.WinError(ctypes.get_last_error())
try:
entry = ThreadEntry()
entry.Size = ctypes.sizeof(entry)
found = self.api.Thread32First(snapshot, ctypes.byref(entry))
while found:
if entry.Owner == self.process.pid:
thread = self.api.OpenThread(0x2, False, entry.Id)
if not thread: raise ctypes.WinError(ctypes.get_last_error())
try:
if self.api.ResumeThread(thread) == 0xffffffff:
raise ctypes.WinError(ctypes.get_last_error())
return
finally:
self.api.CloseHandle(thread)
found = self.api.Thread32Next(snapshot, ctypes.byref(entry))
raise RuntimeError("Не найден поток запуска помощника.")
finally:
self.api.CloseHandle(snapshot)
def terminate(self):
with self.lock:
if os.name == "nt":
if not self.handle:
return
from ctypes import wintypes
capacity = 16
while True:
class ProcessIds(self.ctypes.Structure):
_fields_ = [("Assigned", wintypes.DWORD), ("Count", wintypes.DWORD), ("Ids", self.ctypes.c_size_t * capacity)]
members = ProcessIds()
if self.api.QueryInformationJobObject(self.handle, 3, self.ctypes.byref(members), self.ctypes.sizeof(members), None):
break
if self.ctypes.get_last_error() != 234: # ERROR_MORE_DATA
raise self.ctypes.WinError(self.ctypes.get_last_error())
capacity = max(capacity * 2, members.Assigned)
handles = []
deadline = time.monotonic() + 10
try:
for pid in members.Ids[:members.Count]:
handle = self.api.OpenProcess(0x100000, False, pid) # SYNCHRONIZE
if handle: handles.append(handle)
if not self.api.TerminateJobObject(self.handle, 1):
raise self.ctypes.WinError(self.ctypes.get_last_error())
# Job accounting can reach zero before individual process
# handles signal. Wait for FFmpeg to release its files too.
for handle in handles:
timeout = max(0, int((deadline - time.monotonic()) * 1000))
result = self.api.WaitForSingleObject(handle, timeout)
if result == 258: raise TimeoutError("Не удалось дождаться остановки процессов загрузки.")
if result != 0: raise self.ctypes.WinError(self.ctypes.get_last_error())
finally:
for handle in handles: self.api.CloseHandle(handle)
else:
import signal
try:
os.killpg(self.process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.process.wait(timeout=10)
def close(self):
self.terminate()
with self.lock:
if self.handle:
self.api.CloseHandle(self.handle)
self.handle = None
+40
View File
@@ -0,0 +1,40 @@
import json
import struct
MAX_MESSAGE = 1024 * 1024
def read_exact(stream, length):
data = bytearray()
while len(data) < length:
chunk = stream.read(length - len(data))
if not chunk:
if not data:
return None
raise EOFError("Оборвано сообщение native messaging.")
data.extend(chunk)
return bytes(data)
def read_message(stream):
header = read_exact(stream, 4)
if header is None:
return None
size = struct.unpack("<I", header)[0]
if not 0 < size <= MAX_MESSAGE:
raise ValueError("Превышен размер сообщения.")
raw = read_exact(stream, size)
if raw is None:
raise EOFError("Отсутствует тело сообщения.")
message = json.loads(raw.decode("utf-8"))
if not isinstance(message, dict):
raise ValueError("Сообщение должно быть объектом.")
return message
def write_message(stream, message):
raw = json.dumps(message, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
if len(raw) > MAX_MESSAGE:
raise ValueError("Ответ слишком большой.")
stream.write(struct.pack("<I", len(raw)) + raw)
stream.flush()
+60
View File
@@ -0,0 +1,60 @@
"""Validate YouTube-only cookies and attach them to an in-memory yt-dlp jar."""
from __future__ import annotations
import math
import re
from http.cookiejar import Cookie
DOMAIN = re.compile(r"^(?:[a-z0-9-]+\.)*youtube\.com$", re.I)
NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,256}$")
def validate_cookies(value):
if value is None:
return []
if not isinstance(value, list) or len(value) > 300:
raise ValueError("Некорректные данные входа YouTube.")
result, size = [], 0
for entry in value:
if not isinstance(entry, dict):
raise ValueError("Некорректные данные входа YouTube.")
name, content, domain, path = (entry.get(key) for key in ("name", "value", "domain", "path"))
if not isinstance(domain, str) or len(domain) > 253 or not DOMAIN.fullmatch(domain.lstrip(".")) or domain.startswith(".."):
raise ValueError("Данные входа разрешены только для YouTube.")
if (not isinstance(name, str) or not NAME.fullmatch(name) or not isinstance(content, str) or len(content) > 16384
or any(char in content for char in "\r\n\x00") or not isinstance(path, str) or not path.startswith("/")
or len(path) > 2048 or any(char in path for char in "\r\n\x00")):
raise ValueError("Некорректные данные входа YouTube.")
expires = entry.get("expirationDate")
if expires is not None and (isinstance(expires, bool) or not isinstance(expires, (int, float)) or not math.isfinite(expires) or not 0 <= expires < 2**63):
raise ValueError("Некорректный срок действия входа YouTube.")
size += len((name + content + domain + path).encode("utf-8"))
if size > 512 * 1024:
raise ValueError("Превышен размер данных входа YouTube.")
result.append({"name": name, "value": content, "domain": domain.lower(), "path": path,
"secure": bool(entry.get("secure", True)), "httpOnly": bool(entry.get("httpOnly")),
"hostOnly": bool(entry.get("hostOnly", not domain.startswith("."))), "expirationDate": expires})
return result
def apply_cookies(ydl, cookies):
for entry in validate_cookies(cookies):
expires = entry["expirationDate"]
ydl.cookiejar.set_cookie(Cookie(
version=0, name=entry["name"], value=entry["value"], port=None, port_specified=False,
domain=entry["domain"], domain_specified=not entry["hostOnly"], domain_initial_dot=entry["domain"].startswith("."),
path=entry["path"], path_specified=True, secure=entry["secure"],
expires=int(expires) if expires is not None else None, discard=expires is None,
comment=None, comment_url=None, rest={"HttpOnly": None} if entry["httpOnly"] else {}, rfc2109=False))
def is_access_error(error):
return bool(re.search(r"sign in to confirm|not a bot|cookies.*authentication|login required|log in to|HTTP Error (?:403|410)", str(error), re.I))
def public_error(error, authenticated=False):
message = str(error)
if re.search(r"sign in to confirm|not a bot|cookies.*authentication|login required|log in to", message, re.I):
if authenticated:
return "YouTube отклонил вход браузера или просит проверку. Откройте этот ролик на YouTube, пройдите проверку, затем нажмите «Проверить вход» и повторите загрузку."
return "YouTube отклонил запрос загрузчика и просит проверку на бота. Повторите запрос или обновите yt-dlp в компонентах. Дополнительно можно подключить вход кнопкой «Использовать вход в YouTube». Помощник работает; переустановка не нужна."
return message[:2000]
+289
View File
@@ -0,0 +1,289 @@
"""Verified vendor downloads, staged runtimes and publication through the manager."""
from __future__ import annotations
import concurrent.futures
import hashlib
import importlib.metadata
import json
import os
import re
import shutil
import subprocess
import sys
import time
import threading
import uuid
import zipfile
from pathlib import Path
from urllib.parse import urlsplit
import requests
from host.bundle import ROOT, installation_root, manifest
from host.media import NO_WINDOW, run_small
ALLOWED_HOSTS = {"www.python.org", "nodejs.org", "api.github.com", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com", "pypi.org", "files.pythonhosted.org"}
FFMPEG_ASSET = "ffmpeg-master-latest-win64-gpl-shared.zip"
SETTINGS_LOCK = threading.Lock()
def settings(enabled=None, **changes):
with SETTINGS_LOCK:
return _settings(enabled, **changes)
def _settings(enabled=None, **changes):
installed = installation_root()
if not installed:
return {"enabled": False, "supported": False}
path = installed / "data/update-settings.json"
try: value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError): value = {}
value = {"enabled": value.get("enabled", True) is True, "lastCheck": value.get("lastCheck", 0), **value}
if enabled is not None or changes:
if enabled is not None: value["enabled"] = enabled is True
value.update(changes)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
temporary.replace(path)
return value
def due():
value = settings()
return value["enabled"] and time.time() - value.get("lastCheck", 0) >= 86400
def trusted_url(url):
parsed = urlsplit(url)
if parsed.scheme != "https" or parsed.hostname not in ALLOWED_HOSTS or parsed.username or parsed.password or parsed.port not in {None, 443}:
raise ValueError("Неизвестный источник обновления.")
return url
def get(url, **kwargs):
trusted_url(url)
try:
headers = {"User-Agent": "YouTubeDL/1.3"}
if urlsplit(url).hostname == "api.github.com": headers["Accept"] = "application/vnd.github+json"
response = requests.get(url, headers=headers, timeout=(15, 40), **kwargs)
for item in [*response.history, response]: trusted_url(item.url)
response.raise_for_status()
return response
except requests.RequestException as error:
raise ValueError(f"Не удалось связаться с {urlsplit(url).hostname}. Проверьте подключение и повторите обновление.") from error
def digest(path):
result = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""): result.update(chunk)
return result.hexdigest()
def fetch(item, target, progress):
expected = item.get("sha256", "")
if not re.fullmatch("[a-f0-9]{64}", expected): raise ValueError("Источник не предоставил SHA256 обновления.")
with get(item["url"], stream=True) as response, target.open("wb") as output:
total = int(response.headers.get("Content-Length", 0)); received = 0
for chunk in response.iter_content(512 * 1024):
output.write(chunk); received += len(chunk)
if received > 512 * 1024 * 1024: raise ValueError("Превышен размер компонента.")
progress(received / total if total else None)
if digest(target) != expected: raise ValueError("Контрольная сумма обновления не совпала. Прежний комплект сохранён.")
def safe_members(package):
if sum(item.file_size for item in package.infolist()) > 1024 * 1024 * 1024:
raise ValueError("Превышен размер распакованного компонента.")
for item in package.infolist():
name = item.filename.replace("\\", "/")
parts = name.split("/")
if name.startswith("/") or any(part in {"..", "."} or ":" in part for part in parts) or (item.external_attr >> 16) & 0xF000 == 0xA000:
raise ValueError("Недопустимый путь в обновлении.")
if not item.is_dir(): yield item
def extract_zip(archive, target):
root = target.resolve()
with zipfile.ZipFile(archive) as package:
for item in safe_members(package):
destination = (root / item.filename).resolve()
if root not in destination.parents: raise ValueError("Путь вне папки компонента.")
destination.parent.mkdir(parents=True, exist_ok=True)
with package.open(item) as source, destination.open("wb") as output: shutil.copyfileobj(source, output)
def version_tuple(value):
return tuple(int(part) for part in value.lstrip("v").split(".")) if re.fullmatch(r"v?\d+(?:\.\d+)*", value) else ()
def python_release():
branch = f"{sys.version_info.major}.{sys.version_info.minor}"
current = version_tuple(sys.version.split()[0])
index = get("https://www.python.org/ftp/python/").text
versions = sorted(set(re.findall(r'href="(' + re.escape(branch) + r'\.\d+)/"', index)), key=version_tuple, reverse=True)
for version in versions:
if version_tuple(version) <= current: break
page = get(f"https://www.python.org/downloads/release/python-{version.replace('.', '')}/").text
name = f"python-{version}-embed-amd64.zip"
for row in re.findall(r"<tr\b[^>]*>(.*?)</tr>", page, re.S):
if name not in row: continue
match = re.search(r"\b[a-f0-9]{64}\b", re.sub(r"<[^>]+>", " ", row))
if match:
return {"id": "python", "version": version, "url": f"https://www.python.org/ftp/python/{version}/{name}", "sha256": match[0]}
return None
def node_release():
current = run_small([str(ROOT / "runtime/bin/node.exe"), "--version"]).stdout.strip()
releases = get("https://nodejs.org/dist/index.json").json()
compatible = [item for item in releases if item.get("lts") and "win-x64-zip" in item.get("files", [])
and version_tuple(item["version"])[:1] == version_tuple(current)[:1] and version_tuple(item["version"]) > version_tuple(current)]
if not compatible: return None
version = max(compatible, key=lambda item: version_tuple(item["version"]))["version"]
name = f"node-{version}-win-x64.zip"
checksum = next((line.split()[0] for line in get(f"https://nodejs.org/dist/{version}/SHASUMS256.txt").text.splitlines() if line.split() and line.split()[-1] == name), None)
return {"id": "node", "version": version, "url": f"https://nodejs.org/dist/{version}/{name}", "sha256": checksum}
def ffmpeg_release():
release = get("https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest").json()
asset = next(item for item in release["assets"] if item["name"] == FFMPEG_ASSET)
checksum = asset.get("digest", "").removeprefix("sha256:")
if not re.fullmatch("[a-f0-9]{64}", checksum): raise ValueError("FFmpeg не предоставил SHA256. Обновление отложено.")
previous = next((item.get("sha256") for item in manifest()["sources"] if FFMPEG_ASSET in item["url"]), None)
if previous == checksum: return None
return {"id": "ffmpeg", "version": asset["updated_at"], "url": asset["browser_download_url"], "sha256": checksum}
def packages_release():
installed = {item.metadata["Name"]: item.version for item in importlib.metadata.distributions(path=[str(ROOT / "runtime/python/Lib/site-packages")])}
def newer(item):
name, version = item
release = get(f"https://pypi.org/pypi/{name}/json").json()["info"]["version"]
return version_tuple(release) > version_tuple(version)
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
changed = any(list(executor.map(newer, installed.items())))
return {"id": "packages", "version": "latest-compatible"} if changed else None
def check_updates():
if not installation_root(): raise ValueError("Для обновления установите автономный помощник.")
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
candidates = list(executor.map(lambda task: task(), [python_release, node_release, ffmpeg_release, packages_release]))
return [item for item in candidates if item]
def resolve_packages(stage, work, progress):
progress(None, "Подбираю совместимые версии Python-пакетов…")
release = get("https://pypi.org/pypi/pip/json").json()
wheel = next(item for item in release["urls"] if item["filename"].endswith("-py3-none-any.whl") and not item.get("yanked"))
pip = work / "pip.whl"
fetch({"url": wheel["url"], "sha256": wheel["digests"]["sha256"]}, pip, lambda _: None)
report = work / "python-packages.json"
script = "import sys;sys.path.insert(0,sys.argv.pop(1));from pip._internal.cli.main import main;raise SystemExit(main(sys.argv[1:]))"
command = [str(stage / "runtime/python/python.exe"), "-I", "-X", "utf8", "-B", "-c", script, str(pip), "--isolated", "--disable-pip-version-check", "install",
"--dry-run", "--ignore-installed", "--only-binary=:all:", "--no-cache-dir", "--quiet", "--report", str(report), "--index-url", "https://pypi.org/simple", "yt-dlp[default]", "yt-dlp-ejs"]
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=600, creationflags=NO_WINDOW,
env={**os.environ, "TEMP": str(work), "TMP": str(work)}, stdin=subprocess.DEVNULL)
if result.returncode: raise ValueError("Не удалось подобрать совместимые Python-пакеты. " + result.stderr[-1200:])
records = json.loads(report.read_text(encoding="utf-8"))["install"]
packages = stage / "runtime/python/Lib/site-packages"
if packages.exists(): shutil.rmtree(packages)
packages.mkdir(parents=True)
for index, record in enumerate(records):
info = record["download_info"]; name = record["metadata"]["name"]
archive = work / f"wheel-{index}.whl"
fetch({"url": info["url"], "sha256": info["archive_info"]["hashes"]["sha256"]}, archive,
lambda fraction: progress(None, f"Скачиваю {name}…"))
with zipfile.ZipFile(archive) as wheel:
for member in safe_members(wheel):
relative = member.filename.split("/")
if relative[0].endswith(".data"):
if len(relative) < 3 or relative[1] not in {"purelib", "platlib"}: continue
relative = relative[2:]
destination = packages.joinpath(*relative).resolve()
if packages.resolve() not in destination.parents: raise ValueError("Некорректный путь Python-пакета.")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(wheel.read(member))
return {item["metadata"]["name"]: item["metadata"]["version"] for item in records}
def publish(stage):
installed = installation_root()
manager = installed / "YouTubeDL-Manager.exe"
result = run_small([str(manager), "--activate-release", str(stage), "--expected-current", str(ROOT), "--target-root", str(installed), "--silent"], timeout=180)
if result.returncode: raise ValueError("Новый комплект не прошёл проверку. Прежняя версия сохранена. " + result.stderr[-1000:])
def update(progress, workspace, candidates=None):
installed = installation_root()
if not installed: raise ValueError("Обновление доступно в автономном комплекте.")
progress(None, "Проверяю новые версии компонентов…")
candidates = check_updates() if candidates is None else candidates
settings(lastCheck=time.time())
if not candidates: return {"title": "Все компоненты актуальны", "changed": False}
releases = installed / "releases"
stage = releases / (".ytdl-update-" + uuid.uuid4().hex)
if stage.resolve().parent != releases.resolve() or releases.is_symlink(): raise ValueError("Недопустимая папка обновления.")
if shutil.disk_usage(releases).free < 1024 ** 3: raise ValueError("Для обновления нужен 1 ГБ свободного места.")
stage.mkdir(); workspace(str(stage))
published = False
try:
progress(5, "Готовлю новую версию комплекта…")
owned = json.loads((ROOT / "bundle-files.json").read_text(encoding="utf-8"))
for relative in owned:
source, target = (ROOT / relative).resolve(), (stage / relative).resolve()
if ROOT not in source.parents or stage.resolve() not in target.parents or source.is_symlink():
raise ValueError("Некорректный файл установленного комплекта.")
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
work = stage / ".update-work"; work.mkdir()
metadata = json.loads((stage / "bundle.json").read_text(encoding="utf-8"))
for index, item in enumerate(candidates):
if item["id"] == "packages":
metadata["pythonPackages"] = resolve_packages(stage, work, progress)
continue
archive = work / f"{item['id']}.zip"
label = {"python": "Python", "node": "Node.js", "ffmpeg": "FFmpeg"}[item["id"]]
fetch(item, archive, lambda fraction: progress(10 + (index + (fraction or 0)) / len(candidates) * 60 if fraction is not None else None, f"Скачиваю {label} {item['version']}…"))
if item["id"] == "python":
python = stage / "runtime/python"
saved = work / "site-packages"
shutil.copytree(python / "Lib/site-packages", saved)
shutil.rmtree(python); python.mkdir()
extract_zip(archive, python)
shutil.copytree(saved, python / "Lib/site-packages")
pth = next(python.glob("python*._pth"))
pth.write_text(f"{pth.stem}.zip\n.\nLib/site-packages\n../../\nimport site\n", encoding="ascii")
else:
with zipfile.ZipFile(archive) as package:
for member in safe_members(package):
name = Path(member.filename).name
binary = name == "node.exe" if item["id"] == "node" else "/bin/" in member.filename and (name in {"ffmpeg.exe", "ffprobe.exe"} or name.endswith(".dll"))
if binary: (stage / "runtime/bin" / name).write_bytes(package.read(member))
elif name in {"LICENSE", "LICENSE.txt"}: (stage / "licenses" / f"{label}-LICENSE.txt").write_bytes(package.read(member))
source = {"url": item["url"], "file": Path(urlsplit(item["url"]).path).name, "sha256": item["sha256"]}
metadata["sources"] = [old for old in metadata["sources"] if not (item["id"] == "python" and "python.org" in old["url"] or item["id"] == "node" and "nodejs.org" in old["url"] or item["id"] == "ffmpeg" and FFMPEG_ASSET in old["url"])] + [source]
shutil.rmtree(work)
for item in candidates:
if item["id"] in {"python", "node"}:
executable = stage / ("runtime/python/python.exe" if item["id"] == "python" else "runtime/bin/node.exe")
actual = run_small([str(executable), "--version"]).stdout.strip().removeprefix("Python ")
if actual != item["version"]: raise ValueError("Версия скачанного компонента не совпала с релизом.")
third_party = stage / "licenses/THIRD-PARTY.txt"
if third_party.exists():
third_party.write_text(re.sub(r"yt-dlp source:.*", "yt-dlp source: https://github.com/yt-dlp/yt-dlp (installed version in Python metadata)", third_party.read_text(encoding="utf-8")), encoding="utf-8")
metadata["updatedAt"] = time.time()
(stage / "bundle.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
records = {str(path.relative_to(stage)).replace("\\", "/"): digest(path) for path in stage.rglob("*") if path.is_file() and path.name != "bundle-files.json"}
(stage / "bundle-files.json").write_text(json.dumps(records, indent=2), encoding="utf-8")
progress(85, "Проверяю новый комплект и подключаю помощник…")
publish(stage); published = True
settings(lastUpdate=time.time())
return {"title": "Компоненты обновлены", "changed": True, "restartRequired": True, "components": [item["id"] for item in candidates]}
finally:
# Only our generated, unpublished staging directory can be removed.
if not published and stage.exists() and stage.resolve().parent == releases.resolve() and not stage.is_symlink():
marker = json.loads((installed / "installation.json").read_text(encoding="utf-8-sig"))
if Path(marker["current"]).resolve() != stage.resolve(): shutil.rmtree(stage)
+293
View File
@@ -0,0 +1,293 @@
"""Isolated, cancellable download worker. Stdout contains JSON lines only."""
from __future__ import annotations
import json
import errno
import os
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from host.common import safe_filename, select_streams, summarize, validate_download, youtube_url
from host.media import NO_WINDOW, config, encoding_plan, ffmpeg_command, probe, tool
from host.session import apply_cookies, public_error, is_access_error
from host.ffmpeg_progress import run_ffmpeg
def emit(kind, **data):
print(json.dumps({"type": kind, **data}, ensure_ascii=False, allow_nan=False), flush=True)
class QuietLogger:
def debug(self, message):
pass
def info(self, message):
pass
def warning(self, message):
pass
def error(self, message):
# The raised DownloadError is reported through the structured channel.
pass
def ydl_options():
try:
node = tool("node")
except ValueError:
node = None
options = {"quiet": True, "no_warnings": True, "logger": QuietLogger(), "noplaylist": True,
"socket_timeout": 20, "retries": 3, "fragment_retries": 5,
# Parallel HLS requests can trigger YouTube's fragment throttling;
# never skip failed fragments and silently publish an incomplete clip.
"concurrent_fragment_downloads": 1, "skip_unavailable_fragments": False,
"ffmpeg_location": str(Path(tool("ffmpeg")).parent),
"windowsfilenames": True, "cachedir": False, "noprogress": True}
if node:
options["js_runtimes"] = {"node": {"path": node}}
return options
def inspect(url, cookies=None):
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
for attempt in range(2):
try:
with YoutubeDL(ydl_options()) as ydl:
apply_cookies(ydl, cookies)
return summarize(ydl.extract_info(youtube_url(url), download=False))
except DownloadError as error:
if attempt or not is_access_error(error):
raise
time.sleep(1.5)
def convert(source, target, options, media, encoders):
plan = encoding_plan(options, media, encoders)
duration = float(media.get("format", {}).get("duration") or 0)
attempts = [(plan, True)]
if plan["engine"] in {"nvenc", "amf"}:
attempts.append((plan, False))
if options["engine"] == "auto":
attempts.append(({**plan, "engine": "cpu"}, False))
last_error = ""
for index, (attempt, cuda_decode) in enumerate(attempts):
stage = "encoding" if attempt["video"] not in (None, "copy") else ("audio" if attempt["audio"] not in (None, "copy") else "remuxing")
emit("progress", stage=stage, percent=0, engine=attempt["engine"], codec=attempt["video"] or attempt["audio"],
message="", speed="", eta=None)
command = ffmpeg_command(source, target, options, attempt, cuda_decode)
returncode, errors = run_ffmpeg(command, duration, stage, attempt["engine"], lambda **data: emit("progress", **data))
if returncode == 0 and target.is_file() and target.stat().st_size:
# Verify the result really has the requested streams before publishing it.
output = probe(target)
types = {s.get("codec_type") for s in output.get("streams", [])}
required = {"audio"} if options["mode"] == "audio" else ({"video", "audio"} if options["mode"] == "video" else {"video"})
if not required <= types or (options["mode"] == "video_only" and "audio" in types):
raise ValueError("Проверка итогового файла не прошла: отсутствует выбранная дорожка.")
return attempt
last_error = errors.strip()[-1600:]
if index + 1 < len(attempts):
next_plan, next_decode = attempts[index + 1]
emit("progress", stage="encoding", percent=0, engine=attempt["engine"],
message="Повтор с обычным декодированием" if next_plan["engine"] == attempt["engine"] else "Аппаратное кодирование недоступно: переход на CPU", speed="", eta=None)
raise ValueError("FFmpeg не смог обработать видео. " + last_error)
def publish_file(source: Path, folder: Path, name: str, extension: str) -> Path:
if source.stat().st_dev != folder.stat().st_dev:
# Copy to an unpublished file on the destination disk, then publish it
# atomically. Cancellation cannot leave a partial final filename.
with tempfile.TemporaryDirectory(prefix=".ytdl-", dir=folder) as staging:
staging_path = Path(staging).resolve()
emit("workspace", path=str(staging_path), purpose="saving")
target = staging_path / ("ready." + extension)
total, copied, last_emit = source.stat().st_size, 0, 0.0
emit("progress", stage="saving", percent=0, copied=0, total=total, speed=None, eta=None)
with source.open("rb") as incoming, target.open("xb") as outgoing:
while chunk := incoming.read(4 * 1024 * 1024):
outgoing.write(chunk)
copied += len(chunk)
if time.monotonic() - last_emit >= 0.2:
last_emit = time.monotonic()
emit("progress", stage="saving", percent=min(99, copied / total * 100) if total else 0,
copied=copied, total=total, speed=None, eta=None)
outgoing.flush()
os.fsync(outgoing.fileno())
destination = publish_file(target, folder, name, extension)
source.unlink()
return destination
# Same-volume hard-link creation is atomic and never overwrites an existing file.
for index in range(10000):
suffix = "" if index == 0 else f" ({index})"
destination = folder / f"{name}{suffix}.{extension}"
if destination.exists():
continue
try:
os.link(source, destination)
source.unlink()
return destination
except FileExistsError:
continue
except OSError as error:
if destination.exists():
continue
if error.errno == errno.EXDEV or getattr(error, "winerror", None) == 17:
raise ValueError("Не удалось сохранить файл на выбранный диск.") from error
# Windows rename also refuses to replace existing destinations, including on FAT/exFAT.
if os.name != "nt":
raise
try:
os.rename(source, destination)
return destination
except FileExistsError:
continue
except OSError:
if destination.exists():
continue
raise
raise ValueError("Не удалось выбрать свободное имя файла.")
def download(payload, encoders):
from yt_dlp import YoutubeDL
from yt_dlp.postprocessor.ffmpeg import FFmpegMergerPP, FFmpegPostProcessorError
from yt_dlp.utils import DownloadError
options = validate_download(payload)
emit("progress", stage="preparing", percent=None, message="Получаю свежие ссылки на дорожки")
folder = Path(options["folder"])
work_folder = Path(options["workFolder"])
# Only this job's generated directory is removed on exit; final files live outside it.
# A failed downloader can retain an open .part handle until this worker exits.
# Preserve its actual error; the native host cleans up again after process exit.
with tempfile.TemporaryDirectory(prefix=".ytdl-", dir=work_folder, ignore_cleanup_errors=True) as scratch:
scratch_path = Path(scratch).resolve()
if scratch_path.parent != work_folder.resolve():
raise ValueError("Некорректная временная папка.")
emit("workspace", path=str(scratch_path))
last_emit = 0.0
finished_tracks = set()
track_count = 1
def format_selector(context):
nonlocal track_count
selector = select_streams(context, options)
track_count = 2 if "+" in selector else 1
# Keep extraction and downloading in the same YoutubeDL instance so
# its visitor cookies and request state remain available.
return ydl.build_format_selector(selector)(context)
def reject_live(info, *, incomplete=False):
if info.get("is_live"):
return "Дождитесь окончания прямой трансляции."
def progress(event):
nonlocal last_emit
if event.get("status") == "finished":
finished_tracks.add(event.get("filename", ""))
return
if event.get("status") != "downloading" or time.monotonic() - last_emit < 0.2:
return
last_emit = time.monotonic()
downloaded = event.get("downloaded_bytes", 0)
total = event.get("total_bytes") or event.get("total_bytes_estimate") or 0
track_progress = min(1, downloaded / total) if total else 0
percent = min(99, (len(finished_tracks) + track_progress) / track_count * 100) if total else None
stream = event.get("info_dict", {})
track = "audio" if stream.get("vcodec") == "none" else "video"
emit("progress", stage="downloading", track=track, percent=percent,
downloaded=downloaded, total=total, speed=event.get("speed"), eta=event.get("eta"))
def postprocess(event):
if event.get("status") == "started" and event.get("postprocessor") == "Merger":
emit("progress", stage="merging", percent=0, engine="copy", speed="", eta=None,
processedSeconds=0, durationSeconds=0, written=0, message="Подготовка дорожек к объединению")
class ProgressMerger(FFmpegMergerPP):
def run(self, info):
self.duration = info.get("duration") or 0
return super().run(info)
def run_ffmpeg_multiple_files(self, inputs, output, opts, **kwargs):
self.check_version()
oldest_mtime = min(os.stat(path).st_mtime for path in inputs)
command = [self.executable, "-hide_banner", "-nostdin", "-loglevel", "info", "-y",
"-progress", "pipe:1", "-stats_period", "0.4"]
for path in inputs:
command += ["-i", str(path)]
# Preserve yt-dlp's maps and AAC fixup, including HLS audio.
command += list(opts) + [str(output)]
code, errors = run_ffmpeg(command, self.duration, "merging", "copy", lambda **data: emit("progress", **data))
if code:
raise FFmpegPostProcessorError(errors.strip()[-1600:] or "Не удалось объединить дорожки.")
self.try_utime(output, oldest_mtime, oldest_mtime)
def enable_merge_progress(ydl):
original_run_pp = ydl.run_pp
def run_pp(pp, info):
if isinstance(pp, FFmpegMergerPP) and not isinstance(pp, ProgressMerger):
pp = ProgressMerger(ydl)
return original_run_pp(pp, info)
ydl.run_pp = run_pp
download_options = {**ydl_options(), "format": format_selector, "match_filter": reject_live,
"outtmpl": str(scratch_path / "source.%(ext)s"),
"merge_output_format": "mkv", "progress_hooks": [progress],
"postprocessor_hooks": [postprocess], "overwrites": False}
for attempt in range(2):
try:
with YoutubeDL(download_options) as ydl:
enable_merge_progress(ydl)
apply_cookies(ydl, payload.get("browserCookies"))
downloaded_info = ydl.extract_info(options["url"], download=True)
break
except DownloadError as error:
if attempt == 0 and (is_access_error(error) or "403" in str(error) or "410" in str(error)):
finished_tracks.clear()
emit("progress", stage="preparing", percent=None, message="Обновляю ссылки YouTube и повторяю загрузку")
time.sleep(1.5)
else:
raise
if not downloaded_info:
raise ValueError("Ролик недоступен для загрузки.")
title = str(downloaded_info.get("title", "Видео"))
name = safe_filename(options["filename"] or title, str(downloaded_info.get("id", "video")))
candidates = [p for p in scratch_path.glob("source.*") if p.is_file() and p.suffix.lower() in {".mkv", ".mp4", ".webm", ".m4a", ".mp3", ".ogg", ".opus", ".flac", ".wav", ".ts"}]
if len(candidates) != 1:
raise ValueError("Не удалось определить загруженный медиафайл.")
source = candidates[0]
media = probe(source)
output = scratch_path / f"final.{options['container']}"
plan = convert(source, output, options, media, encoders)
destination = publish_file(output, folder, name, options["container"])
return {"path": str(destination), "filename": destination.name, "title": title,
"size": destination.stat().st_size, "engine": plan["engine"],
"codec": plan["video"] or plan["audio"], "mode": options["mode"]}
def main():
task = {}
try:
task = json.loads(sys.stdin.readline())
if task["action"] == "inspect":
result = inspect(task["payload"]["url"], task["payload"].get("browserCookies"))
elif task["action"] == "download":
result = download(task["payload"], task.get("encoders", task.get("nvenc", [])))
elif task["action"] == "dependency_install":
from host.dependencies import install
result = install(task["payload"]["dependency"],
lambda percent, message: emit("progress", stage="installing", percent=percent, message=message),
lambda path: emit("workspace", path=path))
elif task["action"] == "helper_update":
from host.updates import update
result = update(lambda percent, message: emit("progress", stage="updating", percent=percent, message=message),
lambda path: emit("workspace", path=path), task["payload"].get("candidates"))
else:
raise ValueError("Неизвестная операция.")
emit("result", result=result)
except Exception as error:
emit("error", message=public_error(error, bool(task.get("payload", {}).get("browserCookies"))))
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -0,0 +1,8 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1"
if errorlevel 1 (
echo Installation failed. Read the error above.
pause
exit /b 1
)
pause
+121
View File
@@ -0,0 +1,121 @@
[CmdletBinding()]
param(
[string]$PythonPath,
[string]$FfmpegPath,
[string]$NodePath,
[switch]$RefreshDependencies,
[switch]$SkipRegistration,
[switch]$HelperOnly
)
$ErrorActionPreference = 'Stop'
$taskProjectRoot = $PSScriptRoot
$taskRuntime = Join-Path $taskProjectRoot '.runtime'
New-Item -ItemType Directory -Path $taskRuntime -Force | Out-Null
$taskSavedConfig = $null
if (Test-Path -LiteralPath (Join-Path $taskRuntime 'config.json')) {
try { $taskSavedConfig = Get-Content -LiteralPath (Join-Path $taskRuntime 'config.json') -Raw | ConvertFrom-Json } catch {}
}
function Install-PrivatePython {
$taskPythonRoot = Join-Path $taskRuntime 'python'
$taskPythonInstaller = Join-Path $taskRuntime 'python-setup.exe'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Write-Host 'Installing Python from python.org...'
Invoke-WebRequest -UseBasicParsing -Uri 'https://www.python.org/ftp/python/3.14.8/python-3.14.8-amd64.exe' -OutFile $taskPythonInstaller
$taskSignature = Get-AuthenticodeSignature -LiteralPath $taskPythonInstaller
if ($taskSignature.Status -ne 'Valid') { throw 'Python installer signature verification failed.' }
$taskArguments = @('/quiet','InstallAllUsers=0',"TargetDir=`"$taskPythonRoot`"",'PrependPath=0','Include_launcher=0','Include_test=0','Include_doc=0','Shortcuts=0','AssociateFiles=0')
$taskInstalled = Start-Process -FilePath $taskPythonInstaller -ArgumentList $taskArguments -WindowStyle Hidden -Wait -PassThru
if ($taskInstalled.ExitCode -notin @(0,3010)) { throw 'Python installation failed.' }
return (Join-Path $taskPythonRoot 'python.exe')
}
function Find-Tool([string]$ExplicitPath, [string]$Name) {
if ($ExplicitPath) {
if (-not (Test-Path -LiteralPath $ExplicitPath -PathType Leaf)) { throw "File not found: $ExplicitPath" }
return (Resolve-Path -LiteralPath $ExplicitPath).Path
}
$taskCommand = Get-Command $Name -ErrorAction SilentlyContinue | Select-Object -First 1
if ($taskCommand) { return $taskCommand.Source }
return $null
}
if (-not $PythonPath) {
$taskExistingPython = Join-Path $env:USERPROFILE '.agent-reach-venv\Scripts\python.exe'
$taskProjectPython = Join-Path $taskRuntime 'venv\Scripts\python.exe'
if ($taskSavedConfig.python -and (Test-Path -LiteralPath $taskSavedConfig.python)) { $PythonPath = $taskSavedConfig.python }
elseif (Test-Path -LiteralPath $taskExistingPython) { $PythonPath = $taskExistingPython }
elseif (Test-Path -LiteralPath $taskProjectPython) { $PythonPath = $taskProjectPython }
else {
$taskSystemPython = Find-Tool '' 'python.exe'
if (-not $taskSystemPython -or $taskSystemPython -like '*\WindowsApps\*') { $taskSystemPython = Install-PrivatePython }
& $taskSystemPython -c 'import sys; assert sys.version_info >= (3,10)'
if ($LASTEXITCODE -ne 0) { $taskSystemPython = Install-PrivatePython }
& $taskSystemPython -m venv (Join-Path $taskRuntime 'venv')
if ($LASTEXITCODE -ne 0) { throw 'Python virtual environment creation failed.' }
$PythonPath = $taskProjectPython
}
}
$PythonPath = Find-Tool $PythonPath 'python.exe'
& $PythonPath -c 'import sys; assert sys.version_info >= (3,10), "Python 3.10+ required"'
if ($LASTEXITCODE -ne 0) { throw 'Unsupported Python version.' }
& $PythonPath -c 'import yt_dlp, yt_dlp_ejs'
if ($LASTEXITCODE -ne 0 -or $RefreshDependencies) {
& $PythonPath -m pip install --disable-pip-version-check --upgrade -r (Join-Path $taskProjectRoot 'requirements.txt')
if ($LASTEXITCODE -ne 0) { throw 'Installing yt-dlp failed.' }
}
if (-not $FfmpegPath -and $taskSavedConfig.ffmpeg -and (Test-Path -LiteralPath $taskSavedConfig.ffmpeg)) { $FfmpegPath = $taskSavedConfig.ffmpeg }
$taskFfmpeg = Find-Tool $FfmpegPath 'ffmpeg.exe'
$taskFfprobe = if ($taskFfmpeg) { Join-Path (Split-Path -Parent $taskFfmpeg) 'ffprobe.exe' } else { $null }
if (-not $taskFfprobe -or -not (Test-Path -LiteralPath $taskFfprobe)) { $taskFfprobe = Find-Tool '' 'ffprobe.exe' }
if (-not $NodePath) {
if ($taskSavedConfig.node -and (Test-Path -LiteralPath $taskSavedConfig.node)) { $NodePath = $taskSavedConfig.node }
$taskPortableNode = Join-Path $env:USERPROFILE '.agent-reach\tools\node-v24.21.0-win-x64\node.exe'
if (-not $NodePath -and (Test-Path -LiteralPath $taskPortableNode)) { $NodePath = $taskPortableNode }
}
$taskNode = Find-Tool $NodePath 'node.exe'
$taskUtf8 = [System.Text.UTF8Encoding]::new($false)
$taskConfig = @{ python=$PythonPath; ffmpeg=$taskFfmpeg; ffprobe=$taskFfprobe; node=$taskNode }
[System.IO.File]::WriteAllText((Join-Path $taskRuntime 'config.json'), ($taskConfig | ConvertTo-Json), $taskUtf8)
Push-Location -LiteralPath $taskProjectRoot
try {
if (-not $taskFfmpeg -or -not $taskFfprobe) {
& $PythonPath -m host.dependencies ffmpeg
if ($LASTEXITCODE -ne 0) { throw 'Installing FFmpeg failed.' }
}
$taskNodeGood = $false
if ($taskNode) {
$taskNodeVersion = & $taskNode --version
$taskNodeGood = $taskNodeVersion -match '^v(\d+)\.' -and [int]$Matches[1] -ge 22
}
if (-not $taskNodeGood) {
& $PythonPath -m host.dependencies node
if ($LASTEXITCODE -ne 0) { throw 'Installing Node.js failed.' }
}
} finally { Pop-Location }
$taskLauncherPath = Join-Path $taskRuntime 'launch-host.cmd'
$taskMainPath = Join-Path $taskProjectRoot 'host\main.py'
$taskLauncher = "@echo off`r`nchcp 65001 >nul`r`nset PYTHONIOENCODING=utf-8`r`nset PYTHONUTF8=1`r`n`"$PythonPath`" -u `"$taskMainPath`" %*`r`n"
[System.IO.File]::WriteAllText($taskLauncherPath, $taskLauncher, $taskUtf8)
$taskExtensionId = 'ndnoeiaecgmjnjneekaeapeecmmjheli'
$taskHostName = 'com.youtubedl.native'
$taskNativeManifestPath = Join-Path $taskRuntime 'native-host.json'
$taskNativeManifest = @{ name=$taskHostName; description='YouTubeDL download and NVIDIA NVENC helper'; path=$taskLauncherPath; type='stdio'; allowed_origins=@("chrome-extension://$taskExtensionId/") }
[System.IO.File]::WriteAllText($taskNativeManifestPath, ($taskNativeManifest | ConvertTo-Json), $taskUtf8)
if (-not $SkipRegistration) {
foreach ($taskBrowser in @('Google\Chrome','Microsoft\Edge','Chromium')) {
$taskRegistryPath = "HKCU:\Software\$taskBrowser\NativeMessagingHosts\$taskHostName"
New-Item -Path $taskRegistryPath -Force | Out-Null
Set-Item -Path $taskRegistryPath -Value $taskNativeManifestPath
}
}
Write-Host $(if ($SkipRegistration) { 'Native helper files prepared.' } else { 'Native helper registered for Edge and Chrome (current user).' })
Write-Host "Extension ID: $taskExtensionId"
if ($HelperOnly) {
Write-Host 'Return to YouTubeDL settings and press Start.'
} else {
Write-Host "Extension folder: $(Join-Path $taskProjectRoot 'extension')"
Write-Host 'Open edge://extensions or chrome://extensions, enable Developer mode, and load the extension folder.'
Write-Host 'Then refresh the YouTube page. The Download and Settings buttons are next to Subscribe.'
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "youtubedl",
"version": "1.4.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "youtubedl",
"version": "1.4.5",
"license": "SEE LICENSE IN LICENSE",
"devDependencies": {
"playwright": "^1.64.0"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "youtubedl",
"version": "1.4.5",
"license": "SEE LICENSE IN LICENSE",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/extension.test.mjs tests/session.test.mjs tests/ui-connection.test.mjs tests/native-connection.test.mjs tests/history.test.mjs",
"test:ui": "node tests/ui.test.mjs",
"test:motion": "node tests/progress-motion.test.mjs",
"test:setup": "node tests/onboarding.test.mjs",
"test:versions": "node tests/version-compatibility.test.mjs",
"test:history": "node tests/history-browser.test.mjs",
"test:thumbnails": "node tests/thumbnail-placement.test.mjs",
"test:live-shorts": "node tests/live-shorts.test.mjs",
"test:browser": "node tests/browser-native.test.mjs",
"test:helper": "node tests/helper-lifecycle.test.mjs",
"test:reconnect": "node tests/helper-reconnect.test.mjs",
"test:site": "node tests/site.test.mjs",
"check": "node --check extension/background.js && node --check extension/controls.js && node --check extension/content.js && node --check extension/downloader.js && node --check extension/onboarding.js && node --check extension/ui-connection.js"
},
"devDependencies": {
"playwright": "^1.64.0"
}
}
+1
View File
@@ -0,0 +1 @@
yt-dlp[default]>=2026.8.19
View File
Whitespace-only changes.
+35
View File
@@ -0,0 +1,35 @@
const root = document.documentElement;
const systemDark = matchMedia("(prefers-color-scheme: dark)");
const currentTheme = () => root.dataset.theme || (systemDark.matches ? "dark" : "light");
const themeButton = document.getElementById("theme-toggle");
function labelTheme() { themeButton.setAttribute("aria-label", `Включить ${currentTheme() === "dark" ? "светлую" : "тёмную"} тему`); }
labelTheme();
themeButton.addEventListener("click", () => {
root.dataset.theme = currentTheme() === "dark" ? "light" : "dark";
try { localStorage.setItem("ytdl-site-theme", root.dataset.theme); } catch { /* Session-only theme. */ }
labelTheme();
});
systemDark.addEventListener("change", labelTheme);
for (const button of document.querySelectorAll("[data-screenshot]")) button.addEventListener("click", () => {
const theme = button.dataset.screenshot;
const screenshot = document.getElementById("menu-screenshot");
screenshot.src = `assets/menu-${theme}.png`;
screenshot.alt = `Меню YouTubeDL в ${theme === "dark" ? "тёмной" : "светлой"} теме: качество видео, звук, формат, кодек и папка сохранения`;
for (const peer of document.querySelectorAll("[data-screenshot]")) peer.setAttribute("aria-pressed", String(peer === button));
});
let copyTimer;
for (const button of document.querySelectorAll("[data-copy]")) button.addEventListener("click", async () => {
const status = document.getElementById("copy-status");
try { await navigator.clipboard.writeText(button.dataset.copy); status.textContent = `Скопировано: ${button.dataset.copy}`; }
catch { status.textContent = `Скопируйте адрес вручную: ${button.dataset.copy}`; }
status.hidden = false;
clearTimeout(copyTimer); copyTimer = setTimeout(() => { status.hidden = true; }, 5500);
});
// Content stays visible if JavaScript or IntersectionObserver is unavailable.
if ("IntersectionObserver" in window && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
const observer = new IntersectionObserver(entries => { for (const entry of entries) if (entry.isIntersecting) { entry.target.classList.add("shown"); observer.unobserve(entry.target); } }, { threshold: 0.07 });
for (const element of document.querySelectorAll(".reveal")) { element.classList.add("will-reveal"); observer.observe(element); }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="13" fill="#39bdeb"/><path d="M24 10v20m-8-8 8 8 8-8M12 31v7h24v-7" fill="none" stroke="#0d2b38" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+102
View File
@@ -0,0 +1,102 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="description" content="YouTubeDL — расширение для Edge и Chrome на Windows. Скачивайте видео и Shorts, выбирайте качество, сохраняйте аудио и конвертируйте локально с NVIDIA NVENC или AMD AMF.">
<meta name="theme-color" content="#10161d">
<meta property="og:title" content="YouTubeDL — Видео. Звук. Ваш выбор.">
<meta property="og:description" content="Кнопка скачивания прямо на YouTube. Качество, звук, формат и папка под вашим контролем. Расширение и отдельный помощник для Windows.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://git.lonarpgproxy.space/extention/youtubedl/">
<meta property="og:image" content="https://git.lonarpgproxy.space/extention/youtubedl/assets/github-cover.png">
<meta property="og:locale" content="ru_RU">
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="https://git.lonarpgproxy.space/extention/youtubedl/">
<link rel="icon" type="image/svg+xml" href="assets/mark.svg">
<title>YouTubeDL — Видео. Звук. Ваш выбор.</title>
<script src="theme.js"></script>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<a class="skip" href="#main">Перейти к содержимому</a>
<header class="site-header">
<div class="wrap header-inner">
<a class="brand" href="#" aria-label="YouTubeDL — начало"><img src="assets/mark.svg" alt="" width="34" height="34"><span>YouTubeDL</span></a>
<nav aria-label="Основная навигация"><a href="#features">Возможности</a><a href="#install">Установка</a><a href="#privacy">Данные</a><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL" target="_blank" rel="noopener noreferrer">Gitea <span aria-hidden="true">↗</span></a></nav>
<button class="theme-button" id="theme-toggle" type="button" aria-label="Переключить тему" title="Переключить тему"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M20.4 13A8.5 8.5 0 0 1 11 3.6 8.5 8.5 0 1 0 20.4 13Z"/></svg></button>
</div>
</header>
<main id="main">
<section class="hero" aria-labelledby="hero-title">
<img class="hero-art" src="assets/hero.png" alt="" fetchpriority="high" width="1774" height="887">
<div class="wrap hero-inner">
<div class="hero-copy">
<p class="overline hero-overline"><span class="live-dot" aria-hidden="true"></span> ДЛЯ EDGE И CHROME · WINDOWS</p>
<h1 id="hero-title">Из YouTube.<br>В <span>вашу папку.</span></h1>
<p class="hero-description">Ролики, Shorts или только звук.<br>Выберите качество и формат —<br>остальное сделает YouTubeDL.</p>
<div class="hero-actions"><a class="button primary" href="#install">Установить расширение <span aria-hidden="true">↓</span></a><a class="hero-link" href="#features">Посмотреть возможности <span aria-hidden="true">↗</span></a></div>
<p class="hero-note">Бесплатно · Для Windows · Обработка на вашем компьютере</p>
</div>
<div class="hero-bottom"><span>ВИДЕО. ЗВУК. ВАШ ВЫБОР.</span><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/tag/v1.4.5">Выпуск 1.4.5 <span aria-hidden="true">↗</span></a></div>
</div>
</section>
<div class="format-strip"><div class="wrap"><p>Один ролик.<br><strong>Ваш формат.</strong></p><div class="format-list" aria-label="Форматы"><span>MP4</span><span>MKV</span><span>WebM</span><i aria-hidden="true"></i><span>MP3</span><span>M4A</span><span>Opus</span><span>FLAC</span><span>WAV</span></div></div></div>
<section class="section wrap product-section" id="features" aria-labelledby="features-title">
<div class="product-copy reveal">
<p class="overline">01 / ВСЁ ПОД РУКОЙ</p>
<h2 id="features-title">Один клик.<br>Именно <em>ваши</em><br>настройки.</h2>
<p class="section-lead">Кнопка «Скачать» появляется рядом с «Подписаться», на превью и в Shorts. При нажатии загрузчик использует сохранённые параметры.</p>
<div class="control-example" aria-label="Пример кнопок на YouTube"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5"/></svg>Скачать</span><span class="gear-example" aria-hidden="true">⚙</span></div>
<p class="quiet">Нужно изменить качество, звук или папку? Откройте ⚙ рядом. Без промежуточных меню при каждом скачивании.</p>
<ul class="feature-list"><li><b>Качество источника</b><span>Разрешение, FPS, HDR и язык аудио. Вплоть до 4K/8K, если они доступны у ролика.</span></li><li><b>Видео и звук вместе</b><span>Раздельные дорожки автоматически соединяются. Можно сохранить только видео или только аудио.</span></li><li><b>Файл там, где удобно</b><span>Рабочая папка и папка сохранения могут находиться на разных дисках. Совпадающие имена получают номер; готовые файлы не перезаписываются.</span></li></ul>
</div>
<figure class="product-visual reveal">
<div class="visual-toolbar"><span><i aria-hidden="true"></i> ИНТЕРФЕЙС РАСШИРЕНИЯ</span><div class="screenshot-switch" role="group" aria-label="Тема скриншота"><button type="button" data-screenshot="dark" aria-pressed="true">Тёмная</button><button type="button" data-screenshot="light" aria-pressed="false">Светлая</button></div></div>
<div class="screenshot-stage"><img id="menu-screenshot" src="assets/menu-dark.png" alt="Меню YouTubeDL в тёмной теме: качество видео, звук, формат, кодек и папка сохранения" width="520" height="892" loading="lazy"></div>
<figcaption>Настоящий интерфейс с тестовыми метаданными.<br>Тема меню подстраивается под YouTube; её можно сменить вручную.</figcaption>
</figure>
</section>
<section class="processing-section" aria-labelledby="processing-title"><div class="wrap processing-inner">
<div class="reveal"><p class="overline">02 / БЕЗ ЛИШНИХ ДЕЙСТВИЙ</p><h2 id="processing-title">Две дорожки.<br><em>Один готовый файл.</em></h2><p class="section-lead">Помощник скачивает видео и звук, соединяет их и при необходимости меняет формат. Вы видите каждый этап.</p><p class="quiet">Две операции одновременно, отмена и «Показать файл». Закрытие меню не прерывает скачивание. История загрузок и ошибок открывается в отдельной вкладке.</p></div>
<div class="pipeline reveal" aria-label="Этапы обработки"><div class="tracks"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3Z"/></svg>Видео</span><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M5 10v4m4-8v12m4-15v18m4-14v10m4-6v2"/></svg>Звук</span></div><div class="pipeline-line" aria-hidden="true"></div><div class="merged-file"><img src="assets/mark.svg" alt="" width="36" height="36"><div><b>Готовый файл</b><small>В вашей папке</small></div><span aria-hidden="true">✓</span></div><div class="stages"><span>Скачать</span><span>Объединить</span><span>Сохранить</span></div><p>Совместимые дорожки копируются без перекодирования — это обычно быстрее всего.</p></div>
</div></section>
<section class="section wrap gpu-section" aria-labelledby="gpu-title">
<div class="gpu-intro reveal"><p class="overline">03 / ЖЕЛЕЗО ИМЕЕТ ЗНАЧЕНИЕ</p><h2 id="gpu-title">Если конвертировать,<br>то <em>с ускорением.</em></h2><p class="section-lead">NVIDIA NVENC и AMD AMF для H.264, HEVC и AV1. YouTubeDL определяет карту и проверяет кодировщики пробным запуском FFmpeg.</p></div>
<div class="gpu-facts reveal"><article><span class="fact-number">01</span><div><h3>Проверка, а не наклейка</h3><p>В меню показаны реальная карта и доступные кодеки. В «Авто» при необходимости используется CPU.</p></div></article><article><span class="fact-number">02</span><div><h3>GPU ускоряет конвертацию</h3><p>Скачивание зависит от сети и YouTube. Аудио и VP9 обрабатываются процессором.</p></div></article><article><span class="fact-number">03</span><div><h3>Честно о проверках</h3><p>NVENC проверен на RTX 5080. AMD AMF реализован; успешное кодирование на физической AMD-карте пока не проверено.</p></div></article></div>
</section>
<section class="install-section" id="install" aria-labelledby="install-title"><div class="wrap">
<div class="install-heading reveal"><div><p class="overline">04 / НАЧНЁМ</p><h2 id="install-title">Два скачивания.<br><em>Один комплект.</em></h2></div><p>Windows 10/11 x64<br>Edge или Chrome 120+<br><span>Без прав администратора</span></p></div>
<div class="download-grid reveal">
<article class="download-card"><div class="download-card-top"><span class="package-number">1</span><span class="package-tag">БРАУЗЕР</span><span class="version">v1.4.5</span></div><h3>Расширение</h3><p>Кнопки на YouTube, настройки и уведомления. Устанавливается распакованным.</p><a class="button primary package-link" data-download="extension" href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/download/v1.4.5/YouTubeDL-1.4.5.zip">Скачать расширение <span aria-hidden="true">↓</span></a><small>ZIP · <span id="extension-size">Небольшой архив</span></small></article>
<article class="download-card helper-card"><div class="download-card-top"><span class="package-number">2</span><span class="package-tag">КОМПЬЮТЕР</span><span class="version">v1.3.2</span></div><h3>Локальный помощник</h3><p>Скачивает, соединяет и конвертирует. Все необходимые компоненты уже внутри.</p><a class="button secondary package-link" data-download="helper" href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/download/v1.4.5/YouTubeDL-Helper-Setup.exe">Скачать помощник <span aria-hidden="true">↓</span></a><small>EXE · 124,3 МиБ · устанавливается отдельно</small></article>
</div>
<p class="download-note">Оба файла размещены в <a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest">релизах Gitea ↗</a>. Помощник не встроен в ZIP. <a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/INSTALL.md#проверка-скачанных-файлов">Проверка SHA256 и подробности</a>.</p>
<ol class="install-steps reveal">
<li><span>01</span><div><h3>Распакуйте и загрузите расширение</h3><p>Оставьте распакованный ZIP в постоянной папке. Откройте страницу расширений, включите «Режим разработчика» → «Загрузить распакованное расширение» → выберите папку <code>extension</code>.</p><div class="browser-addresses"><button type="button" class="copy-address" data-copy="edge://extensions">edge://extensions <span>Копировать</span></button><button type="button" class="copy-address" data-copy="chrome://extensions">chrome://extensions <span>Копировать</span></button></div></div></li>
<li><span>02</span><div><h3>Запустите помощник</h3><p>Откройте отдельно скачанный <code>YouTubeDL-Helper-Setup.exe</code>. Дождитесь установки. Python, yt-dlp, EJS, Node.js, FFmpeg и FFprobe уже в комплекте; для установки после скачивания интернет не нужен.</p></div></li>
<li><span>03</span><div><h3>Обновите YouTube и пройдите мастер</h3><p>Откройте ⚙, нажмите «Проверить подключение», выберите папку, качество и формат. Зелёный индикатор подтверждает реальный ответ помощника. Затем загрузчик будет использовать эти настройки.</p></div></li>
</ol>
<p class="full-guide"><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/INSTALL.md">Полная инструкция, обновление и удаление <span aria-hidden="true">↗</span></a></p>
</div></section>
<section class="section wrap privacy-section" id="privacy" aria-labelledby="privacy-title">
<div class="reveal"><p class="overline">05 / ПОНЯТНО О ДАННЫХ</p><h2 id="privacy-title">Ваш компьютер.<br><em>Ваша обработка.</em></h2><p class="section-lead">У проекта нет аналитики, рекламы и сервера для конвертации ваших роликов. Исходники доступны для просмотра.</p><a class="text-link" href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/PRIVACY.md">Прочитать полный список данных и разрешений ↗</a></div>
<dl class="privacy-facts reveal"><div><dt>Что остаётся локально</dt><dd>Настройки, папка и до 30 последних операций в браузере: названия, ссылки, статусы, пути и ошибки. Помощник отдельно хранит до 30 готовых загрузок. GPU и версии компонентов проверяются для работы приложения.</dd></div><div><dt>Вход — только по согласию</dt><dd>Доступ к cookies YouTube необязателен. Они передаются локальному помощнику и используются в запросах к YouTube; в файлы cookies и историю не записываются. Разрешение можно отозвать.</dd></div><div><dt>Куда идут запросы</dt><dd>К YouTube и его медиасерверам, к официальным источникам обновлений и GitHub для скачиваний и сайта. Эти сервисы видят обычные данные сетевого запроса, включая IP.</dd></div></dl>
</section>
<section class="faq-section"><div class="wrap faq-inner"><div><p class="overline">ОСТАЛИСЬ ДЕТАЛИ?</p><h2>Частые вопросы.</h2><p class="quiet">Если ответа здесь нет,<br><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/issues">напишите в задачи проекта ↗</a></p></div><div class="faqs">
<details><summary>Почему нужен отдельный помощник?<span aria-hidden="true">+</span></summary><p>Браузерное расширение не может запускать FFmpeg и напрямую работать с произвольной папкой Windows. Локальный Native Messaging помощник выполняет скачивание и обработку. Все зависимости собраны в один EXE; отдельно устанавливать Python или FFmpeg не нужно.</p></details>
<details><summary>Как работают обновления?<span aria-hidden="true">+</span></summary><p>Обновление компонентов проверяет и скачивает совместимые обновления Python, Node.js, yt-dlp с библиотеками, EJS и FFmpeg/FFprobe. Автопроверка включена по умолчанию: раз в сутки, пока помощник работает, между операциями. Её можно выключить. Новые версии самого расширения и кода помощника устанавливаются вручную из Releases.</p></details>
<details><summary>Нужно входить в YouTube?<span aria-hidden="true">+</span></summary><p>Для многих открытых роликов — нет. Если YouTube требует вход или проверку, откройте ролик в текущем профиле и при необходимости включите «Использовать вход в YouTube». Вход на сайте сам по себе не авторизует отдельный загрузчик. Авторизация не гарантирует обход ограничений.</p></details>
<details><summary>Будет ли работать 4K, 8K и HDR?<span aria-hidden="true">+</span></summary><p>Если эти дорожки есть у источника. Разрешение не увеличивается искусственно. Для HDR используйте исходный кодек, HEVC или AV1; HDR → H.264 без тонального преобразования отклоняется. FLAC/WAV тоже не восстанавливают уже потерянное качество звука.</p></details>
<details><summary>Как удалить всё?<span aria-hidden="true">+</span></summary><p>Удалите помощник кнопкой в меню или через приложения Windows, затем расширение из браузера. Скачанные файлы и история помощника сохраняются. Для удаления истории после удаления помощника удалите <code>%LOCALAPPDATA%\YouTubeDL\Helper\data</code>. Настройки расширения удаляются вместе с расширением.</p></details>
<details><summary>Есть другие ОС или магазин расширений?<span aria-hidden="true">+</span></summary><p>Сейчас опубликована сборка для Windows 10/11 x64 и Edge/Chrome 120+. Firefox, macOS, Linux и Windows ARM не входят в неё. В магазинах браузеров расширение пока не опубликовано. Плейлисты, скачивание каналов и DRM-контент не заявляются.</p></details>
<details><summary>Можно изменять расширение и публиковать свои версии?<span aria-hidden="true">+</span></summary><p><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/LICENSE">Лицензия</a> разрешает использование по назначению и запрещает изменения, самостоятельные форки и распространение собственного кода без письменного разрешения, с учётом исключений в её тексте. Сторонние компоненты сохраняют свои лицензии.</p></details>
</div></div></section>
<section class="closing"><div class="wrap"><img src="assets/mark.svg" alt="" width="48" height="48"><h2>Видео. Звук.<br><span>Ваш выбор.</span></h2><a class="button primary" href="#install">Скачать YouTubeDL <span aria-hidden="true">↓</span></a></div></section>
</main>
<footer class="site-footer"><div class="wrap footer-top"><a class="brand" href="#"><img src="assets/mark.svg" alt="" width="28" height="28"><span>YouTubeDL</span></a><div><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL">Исходники ↗</a><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/PRIVACY.md">Данные</a><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/LICENSE">Лицензия</a><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/src/branch/main/THIRD_PARTY.md">Компоненты</a><a href="https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/issues">Поддержка</a></div></div><div class="wrap footer-bottom"><p>© 2026 OwnerLink. YouTubeDL распространяется по ограниченной пользовательской лицензии.</p><p>Независимый проект. Не связан с YouTube/Google, NVIDIA или AMD. Сохраняйте контент, который вы вправе скачивать.</p><p>На сайте нет счётчиков и собственных cookies. Хостинг — собственный сервер проекта.</p></div></footer>
<div id="copy-status" class="copy-status" role="status" aria-live="polite" hidden></div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://git.lonarpgproxy.space/extention/youtubedl/sitemap.xml
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://git.lonarpgproxy.space/extention/youtubedl/</loc></url></urlset>
+35
View File
@@ -0,0 +1,35 @@
@layer base, layout, components, responsive;
@layer base {
:root{color-scheme:light;--paper:#f6f6f2;--surface:#fff;--ink:#18232a;--muted:#5b686f;--line:#dce1de;--soft:#e9eeeb;--cyan:#39bdeb;--accent:#007b98;--radius:20px;font-family:Inter,"Segoe UI",Arial,sans-serif;font-synthesis:none}
@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--paper:#10171d;--surface:#182129;--ink:#ecf2f4;--muted:#a5b4bd;--line:#2d3a43;--soft:#1a262d;--accent:#71d9ef}}
:root[data-theme="dark"]{color-scheme:dark;--paper:#10171d;--surface:#182129;--ink:#ecf2f4;--muted:#a5b4bd;--line:#2d3a43;--soft:#1a262d;--accent:#71d9ef}
*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:94px}body{margin:0;background:var(--paper);color:var(--ink);line-height:1.6}a{color:inherit;text-decoration:none}button{font:inherit;cursor:pointer}img{max-width:100%;display:block}svg{width:22px;height:22px;flex-shrink:0}button,a{-webkit-tap-highlight-color:transparent}a:focus-visible,button:focus-visible,summary:focus-visible{outline:3px solid var(--cyan);outline-offset:5px}button{color:inherit}h1,h2,h3,p,figure{margin:0}h1,h2{line-height:1.05;letter-spacing:-.055em;font-weight:650}h2{font-size:clamp(36px,4.1vw,58px)}h3{line-height:1.25;font-weight:650}em{font-style:normal;color:var(--accent)}[hidden]{display:none!important}code{font-family:Consolas,monospace;font-size:.88em;overflow-wrap:anywhere}::selection{background:#39bdeb;color:#0b202b}
.skip{position:fixed;top:8px;left:16px;z-index:100;background:var(--cyan);color:#0a2633;padding:10px 18px;transform:translateY(-150%)}.skip:focus{transform:none}
.wrap{width:min(1180px,calc(100% - 96px));margin-inline:auto}.section{padding-block:110px}.overline{font-size:11px;font-weight:700;letter-spacing:.16em;color:var(--muted);margin-bottom:24px}.section-lead{font-size:18px;color:var(--muted);line-height:1.7;max-width:490px;margin-top:30px}.quiet{font-size:14px;color:var(--muted);line-height:1.75}.text-link{display:inline-block;font-size:14px;font-weight:600;border-bottom:1px solid var(--line);padding-block:5px;margin-top:28px;color:var(--accent)}a:hover{color:var(--accent)}.will-reveal{opacity:0;transform:translateY(18px);transition:opacity .65s,transform .65s}.will-reveal.shown{opacity:1;transform:none}
}
@layer layout {
.site-header{background:var(--paper);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}.header-inner{display:flex;align-items:center;gap:30px;min-height:78px}.brand{display:inline-flex;align-items:center;gap:10px;font-size:20px;font-weight:750;letter-spacing:-.035em;white-space:nowrap}.header-inner nav{display:flex;gap:30px;margin-left:auto;font-size:13px;font-weight:600}.header-inner nav a{padding-block:12px}.theme-button{width:36px;height:36px;display:grid;place-items:center;flex-shrink:0;border:1px solid var(--line);background:transparent;border-radius:50%}.theme-button:hover{background:var(--soft)}
.hero{position:relative;overflow:hidden;background:#10161d;color:#eff7fa;min-height:640px}.hero-art{position:absolute;height:100%;width:100%;max-width:none;object-fit:cover;object-position:center right;opacity:.85}.hero:after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,#10161d 0%,#10161de6 25%,transparent 57%);pointer-events:none}.hero-inner{position:relative;z-index:1;min-height:640px;display:flex;flex-direction:column}.hero-copy{padding-top:76px;max-width:540px}.hero-overline{color:#aac1cd;margin-bottom:28px;display:flex;align-items:center;gap:9px}.live-dot{width:6px;height:6px;background:#79e3d1;border-radius:50%}.hero h1{font-size:clamp(46px,5.9vw,82px)}.hero h1 span{color:#8de7e1}.hero-description{font-size:18px;color:#bed0db;line-height:1.7;margin-top:27px}.hero-actions{display:flex;align-items:center;gap:23px;flex-wrap:wrap;margin-top:32px}.hero-link{font-size:12px;color:#d4e4ed}.hero-link:hover{color:#91eee2}.hero-note{font-size:11px;color:#8cabbc;margin-top:19px}.hero-bottom{margin-top:auto;padding:32px 0 22px;display:flex;justify-content:space-between;align-items:center;color:#7f9ba8;font-size:10px;letter-spacing:.15em}.hero-bottom a{letter-spacing:0;color:#bfced6;font-size:12px}
.format-strip{border-bottom:1px solid var(--line)}.format-strip>.wrap{display:flex;align-items:center;gap:66px;min-height:115px}.format-strip p{font-size:12px;color:var(--muted);line-height:1.5;white-space:nowrap}.format-strip strong{color:var(--ink)}.format-list{display:flex;align-items:center;justify-content:space-between;gap:26px;width:100%;font-size:19px;letter-spacing:-.035em;font-weight:650;color:var(--muted)}.format-list i{width:1px;height:22px;background:var(--line);flex-shrink:0}
.product-section{display:grid;grid-template-columns:1fr 1fr;gap:98px;align-items:start}.product-copy .section-lead{font-size:16px}.product-copy .quiet{max-width:390px}.product-visual{padding-top:8px;min-width:0}.visual-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:18px;font-size:9px;letter-spacing:.12em;color:var(--muted);white-space:nowrap}.visual-toolbar>span{display:flex;align-items:center;gap:7px}.visual-toolbar i{width:5px;height:5px;border-radius:50%;background:var(--accent)}.screenshot-stage{background:#dae5e3;padding:22px;border-radius:26px;box-shadow:0 20px 45px #102b3810}.screenshot-stage img{width:100%;height:auto;border-radius:13px;border:1px solid #ffffff18;box-shadow:0 10px 25px #08101a30}.product-visual figcaption{font-size:11px;line-height:1.7;color:var(--muted);margin-top:18px;text-align:center}
.processing-section{background:var(--soft);border-block:1px solid var(--line);padding-block:92px}.processing-inner{display:grid;grid-template-columns:1fr 1fr;gap:110px;align-items:center}.processing-inner .quiet{max-width:420px;margin-top:20px}.gpu-section{display:grid;grid-template-columns:1fr 1fr;gap:100px;align-items:start}.gpu-facts{padding-top:35px}.gpu-facts article{display:flex;gap:25px;padding:25px 0;border-top:1px solid var(--line)}.gpu-facts h3{font-size:18px;margin-bottom:10px}.gpu-facts p{font-size:14px;color:var(--muted);line-height:1.75}.fact-number{font-size:12px;color:var(--accent);padding-top:2px;font-family:Consolas,monospace}
.install-section{background:var(--surface);padding-block:95px;border-block:1px solid var(--line)}.install-heading{display:flex;justify-content:space-between;gap:25px;align-items:end;margin-bottom:45px}.install-heading>p{font-size:14px;color:var(--muted);line-height:1.9;text-align:right}.install-heading>p span{font-size:12px}.download-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.download-note{font-size:12px;color:var(--muted);line-height:1.8;max-width:880px;margin-top:20px}.download-note a{text-decoration:underline;text-underline-offset:3px}.install-steps{list-style:none;padding:0;margin:55px 0 0;counter-reset:steps}.install-steps li{display:grid;grid-template-columns:85px 1fr;gap:25px;padding:32px 0;border-top:1px solid var(--line)}.install-steps li>span{font-size:32px;line-height:1.2;letter-spacing:-.05em;color:var(--accent)}.install-steps h3{font-size:20px;margin-bottom:10px}.install-steps p{font-size:14px;color:var(--muted);max-width:800px}.browser-addresses{display:flex;gap:12px;margin-top:17px;flex-wrap:wrap}.full-guide{font-size:14px;padding-top:12px;color:var(--accent)}
.privacy-section{display:grid;grid-template-columns:1fr 1fr;gap:100px}.privacy-facts{margin:20px 0 0}.privacy-facts>div{padding:22px 0;border-top:1px solid var(--line)}.privacy-facts dt{font-weight:650;font-size:17px;margin-bottom:10px}.privacy-facts dd{margin:0;font-size:14px;color:var(--muted);line-height:1.8}.faq-section{padding-block:75px;border-top:1px solid var(--line)}.faq-inner{display:grid;grid-template-columns:.85fr 1.15fr;gap:100px}.faq-inner h2{font-size:38px}.faq-inner .quiet{margin-top:25px}.faq-inner .quiet a{color:var(--accent)}.closing{background:#10161d;color:#f1f8fa;text-align:center;padding:76px 0 83px;overflow:hidden}.closing img{margin:0 auto 25px}.closing h2{font-size:clamp(40px,5vw,62px);margin-bottom:32px}.closing h2 span{color:#8de7e1}.site-footer{padding-block:30px 35px}.footer-top{display:flex;justify-content:space-between;gap:30px;align-items:center;padding-bottom:25px;border-bottom:1px solid var(--line)}.footer-top .brand{font-size:17px}.footer-top>div{display:flex;flex-wrap:wrap;gap:24px;font-size:12px}.footer-bottom{padding-top:20px;font-size:11px;color:var(--muted);line-height:1.9}.footer-bottom p+p{margin-top:5px}
}
@layer components {
.button{display:inline-flex;align-items:center;justify-content:center;gap:24px;min-height:51px;padding:13px 24px;border-radius:9px;font-size:13px;font-weight:700;line-height:1.4;border:1px solid transparent;transition:background .18s,box-shadow .18s}.button.primary{background:var(--cyan);color:#082937}.button.primary:hover{background:#6bceee;box-shadow:0 4px 14px #39bdeb22}.button.secondary{background:var(--ink);color:var(--paper)}.button.secondary:hover{opacity:.9}.button span{font-size:22px;line-height:1}
.control-example{display:flex;align-items:center;gap:8px;margin:25px 0 13px}.control-example>span{display:inline-flex;align-items:center;justify-content:center;gap:10px;background:var(--soft);border:1px solid var(--line);height:40px;padding-inline:18px;border-radius:22px;font-size:14px;font-weight:650}.control-example svg{width:19px;height:19px}.control-example>.gear-example{width:40px;padding:0;font-size:23px}
.feature-list{list-style:none;margin:28px 0 0;padding:0}.feature-list li{padding:18px 0;border-top:1px solid var(--line)}.feature-list b{display:block;font-size:15px;font-weight:650}.feature-list span{display:block;font-size:13px;color:var(--muted);line-height:1.8;margin-top:6px}
.screenshot-switch{display:flex;gap:3px;background:var(--soft);padding:3px;border-radius:8px;letter-spacing:0}.screenshot-switch button{font-size:10px;border:0;background:transparent;padding:5px 10px;border-radius:5px;color:var(--muted)}.screenshot-switch [aria-pressed="true"]{background:var(--surface);color:var(--ink);box-shadow:0 1px 3px #0001}
.pipeline{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:34px 38px}.tracks{display:flex;gap:14px}.tracks>span{display:flex;align-items:center;justify-content:center;gap:12px;background:var(--soft);border:1px solid var(--line);border-radius:10px;min-height:70px;flex:1;font-size:15px}.tracks svg{color:var(--accent);width:25px;height:25px}.pipeline-line{height:45px;width:52%;margin:auto;border:1px solid var(--line);border-top:0;border-radius:0 0 12px 12px;position:relative}.pipeline-line:after{content:"";position:absolute;left:50%;top:100%;height:18px;border-left:1px solid var(--line)}.merged-file{display:flex;align-items:center;gap:13px;margin-top:18px;background:var(--soft);border:1px solid var(--line);border-radius:10px;padding:19px}.merged-file b{display:block;font-size:15px}.merged-file small{display:block;font-size:11px;color:var(--muted);margin-top:2px}.merged-file>span{margin-left:auto;color:var(--accent);font-size:21px}.stages{display:flex;gap:4px;padding-top:24px}.stages span{flex:1;font-size:10px;color:var(--muted);border-top:3px solid #61c6d2;padding-top:7px}.stages span:nth-child(2){border-color:#92ddd8}.stages span:nth-child(3){border-color:#bcded4}.pipeline>p{font-size:11px;line-height:1.8;color:var(--muted);margin-top:18px}
.download-card{border:1px solid var(--line);border-radius:15px;padding:28px;background:var(--paper);min-width:0;display:flex;flex-direction:column}.download-card-top{display:flex;align-items:center;gap:12px;margin-bottom:25px}.package-number{width:30px;height:30px;display:grid;place-items:center;background:var(--soft);border-radius:50%;font-weight:650;font-size:13px}.package-tag{font-size:10px;font-weight:600;letter-spacing:.12em;color:var(--muted)}.version{margin-left:auto;font-family:Consolas,monospace;font-size:12px;color:var(--muted)}.download-card h3{font-size:28px;letter-spacing:-.04em;margin-bottom:13px}.download-card p{font-size:14px;color:var(--muted);line-height:1.75;max-width:380px;margin-bottom:25px}.download-card .button{margin-top:auto;width:100%;justify-content:space-between}.download-card small{font-size:11px;color:var(--muted);margin-top:12px;text-align:center}.helper-card{background:var(--surface)}
.copy-address{background:var(--paper);border:1px solid var(--line);border-radius:7px;padding:9px 12px;display:flex;align-items:center;gap:14px;font-family:Consolas,monospace;font-size:11px}.copy-address span{font-family:"Segoe UI",sans-serif;font-size:10px;color:var(--accent)}.copy-address:hover{background:var(--soft)}.copy-status{position:fixed;bottom:25px;left:50%;transform:translateX(-50%);z-index:20;border:1px solid var(--line);background:var(--ink);color:var(--paper);box-shadow:0 5px 35px #0002;padding:12px 20px;border-radius:10px;font-size:13px;max-width:calc(100% - 32px);width:max-content}
.faqs details{border-bottom:1px solid var(--line)}.faqs details:first-child{border-top:1px solid var(--line)}.faqs summary{display:flex;align-items:center;justify-content:space-between;gap:18px;list-style:none;cursor:pointer;font-size:15px;font-weight:650;line-height:1.5;padding:21px 0}.faqs summary::-webkit-details-marker{display:none}.faqs summary span{color:var(--accent);font-size:22px;font-weight:400;transition:transform .2s}.faqs details[open] summary span{transform:rotate(45deg)}.faqs details p{font-size:13px;color:var(--muted);line-height:1.85;padding:0 24px 23px 0}
}
@layer responsive {
@media(min-width:1600px){.hero-art{object-position:center;object-fit:contain}}
@media(max-width:1000px){.wrap{width:calc(100% - 56px)}.section{padding-block:80px}.product-section,.processing-inner,.gpu-section,.privacy-section,.faq-inner{gap:45px}.header-inner nav{gap:20px}.hero-art{object-position:57% center;opacity:.64}.hero:after{background:linear-gradient(90deg,#10161df5 0%,#10161daa 32%,transparent 85%)}.hero h1{font-size:64px}.format-strip>.wrap{gap:30px}.format-list{font-size:16px;gap:16px}.screenshot-stage{padding:14px}.visual-toolbar>span{font-size:8px;letter-spacing:.05em}.screenshot-switch button{padding:5px 7px}.pipeline{padding:25px}.install-heading h2{font-size:46px}}
@media(max-width:700px){.wrap{width:calc(100% - 40px)}html{scroll-padding-top:115px}.section{padding-block:65px}.header-inner{gap:12px;min-height:98px;flex-wrap:wrap;padding:13px 0 9px}.header-inner>.brand{font-size:18px}.header-inner>.brand img{width:29px;height:29px}.header-inner nav{order:3;width:100%;gap:0;justify-content:space-between;font-size:11px}.header-inner nav a{padding-block:4px}.theme-button{margin-left:auto;width:31px;height:31px}.hero,.hero-inner{min-height:720px}.hero-copy{padding-top:42px;max-width:420px}.hero h1{font-size:clamp(47px,10.5vw,66px)}.hero-overline{font-size:9px;margin-bottom:22px}.hero-description{font-size:16px;margin-top:23px}.hero-actions{margin-top:25px;gap:16px}.hero-actions .button{font-size:12px;padding-inline:18px}.hero-link{font-size:11px}.hero-note{font-size:10px;max-width:290px;line-height:1.8}.hero-art{height:59%;width:118%;bottom:10px;right:-9%;object-position:center;opacity:.7}.hero:after{background:linear-gradient(180deg,#10161d 0%,#10161de6 35%,#10161d77 58%,transparent 80%)}.hero-bottom{font-size:8px;padding-bottom:17px}.hero-bottom a{font-size:10px}.format-strip>.wrap{padding-block:20px;min-height:0;gap:20px}.format-strip p{font-size:10px}.format-list{font-size:14px;justify-content:flex-start;gap:12px;flex-wrap:wrap}.format-list i{display:none}.product-section,.processing-inner,.gpu-section,.privacy-section,.faq-inner{grid-template-columns:1fr;gap:36px}.product-copy h2 br:nth-of-type(2){display:none}h2{font-size:41px}.section-lead{font-size:16px;margin-top:22px}.product-copy .quiet{max-width:none}.feature-list li{padding:16px 0}.visual-toolbar{font-size:9px}.visual-toolbar>span{font-size:9px}.screenshot-switch button{font-size:10px;padding:5px 9px}.product-visual{max-width:480px;justify-self:center;width:100%}.screenshot-stage{padding:16px}.processing-section{padding-block:60px}.pipeline{padding:27px}.gpu-facts{padding-top:0}.gpu-facts article{padding-block:20px}.install-section{padding-block:60px}.install-heading{align-items:start;flex-direction:column;gap:22px;margin-bottom:30px}.install-heading h2{font-size:41px}.install-heading>p{text-align:left;font-size:12px;line-height:1.7}.download-grid{grid-template-columns:1fr;gap:15px}.download-card{padding:23px}.download-card-top{margin-bottom:20px}.download-card h3{font-size:26px}.install-steps{margin-top:35px}.install-steps li{grid-template-columns:35px 1fr;gap:18px;padding:25px 0}.install-steps li>span{font-size:24px}.install-steps h3{font-size:18px}.install-steps p{font-size:13px}.browser-addresses{gap:8px}.copy-address{font-size:10px;gap:10px;padding:9px}.privacy-facts{margin-top:0}.privacy-facts dt{font-size:16px}.faq-section{padding-block:55px}.faq-inner h2{font-size:35px}.faq-inner .quiet br{display:none}.faqs summary{font-size:14px}.closing{padding-block:56px}.footer-top{align-items:start;flex-direction:column;gap:20px}.footer-top>div{gap:20px;font-size:11px}.footer-bottom{font-size:10px}}
@media(max-width:360px){h2,.install-heading h2{font-size:35px}.hero h1{font-size:44px}.visual-toolbar{flex-wrap:wrap}.product-section,.processing-inner,.gpu-section,.privacy-section,.faq-inner{grid-template-columns:minmax(0,1fr)}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation:none!important;transition:none!important}.will-reveal{opacity:1;transform:none}}
}
+5
View File
@@ -0,0 +1,5 @@
// Apply the preference before CSS loads; unavailable storage never blocks the page.
(() => {
try { const theme = localStorage.getItem("ytdl-site-theme"); if (["light", "dark"].includes(theme)) document.documentElement.dataset.theme = theme; }
catch { /* System theme remains the default. */ }
})();
+33
View File
@@ -0,0 +1,33 @@
"""On this NVIDIA machine: real AMF initialization failure and automatic CPU fallback."""
import contextlib
import io
import json
import subprocess
import sys
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT))
from host.media import capabilities, probe, tool
from host.worker import convert
hardware = capabilities()
if hardware["amf"]:
raise RuntimeError("This negative-path check is intended for a machine without AMD AMF.")
folder = PROJECT / "test-artifacts/media"; folder.mkdir(parents=True, exist_ok=True)
source = folder / "amd-fallback-input.mkv"
subprocess.run([tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=s=320x180:r=24", "-f", "lavfi", "-i", "sine=frequency=440", "-t", "0.4", "-c:v", "libx264", "-c:a", "aac", str(source)], check=True, capture_output=True)
media = probe(source)
options = {"mode": "video", "container": "mp4", "codec": "hevc", "engine": "auto", "preset": "fast", "bitrate": 192}
events = io.StringIO()
with contextlib.redirect_stdout(events):
plan = convert(source, folder / "amd-fallback-result.mp4", options, media, {"amf": ["hevc"]})
assert plan["engine"] == "cpu"
assert {item["codec_type"] for item in probe(folder / "amd-fallback-result.mp4")["streams"]} == {"video", "audio"}
with contextlib.redirect_stdout(io.StringIO()):
try: convert(source, folder / "amd-forced-result.mp4", {**options, "engine": "amf"}, media, {"amf": ["hevc"]})
except ValueError as error: assert "FFmpeg" in str(error)
else: raise AssertionError("Forced AMD mode must not silently use the CPU")
report = {"gpu": hardware["gpu"], "amfMeasured": hardware["amf"], "nvencMeasured": hardware["nvenc"], "autoFallback": "CPU passed", "forcedAMF": "rejected", "physicalAMDEncoding": "not tested: AMD GPU absent"}
(PROJECT / "test-artifacts/amd-fallback.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print("AMD negative-path checks passed: real AMF failure, automatic CPU fallback with audio, and forced-mode error.")
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root=path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts=path.join(root,'test-artifacts');
await fs.mkdir(artifacts,{recursive:true});
const profile=await fs.mkdtemp(path.join(os.tmpdir(),'ytdl-components-test-'));
const context=await chromium.launchPersistentContext(profile,{
headless:true,viewport:{width:1000,height:900},
executablePath:'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
ignoreDefaultArgs:['--disable-extensions'],args:['--enable-unsafe-extension-debugging'],
});
try {
const cdp=await context.browser().newBrowserCDPSession();
const {id}=await cdp.send('Extensions.loadUnpacked',{path:path.join(root,'extension')});
const worker=context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
await worker.evaluate(() => chrome.storage.local.set({ onboarding: { state: "complete", version: 1 } }));
const page=await context.newPage(); let installerDownloads=0;
page.on('download',()=>installerDownloads++);
await page.goto(`chrome-extension://${id}/downloader.html`);
await page.locator('#helper-panel[data-state="running"]').waitFor({timeout:90000});
await page.locator('#helper-panel > summary').click();
assert.equal(await page.locator('#helper-install').textContent(),'Переустановить');
const automatic=await page.locator('#auto-update').isChecked();
if(automatic) await page.locator('#auto-update').uncheck();
await page.locator('#helper-update').click();
const deadline=Date.now()+240000; let job;
while(Date.now()<deadline) {
const jobs=await worker.evaluate(async()=>Object.values((await chrome.storage.local.get('jobs')).jobs || {}));
job=jobs.find(item=>item.kind==='update');
if(job && job.status!=='running') break;
await new Promise(resolve=>setTimeout(resolve,200));
}
assert.ok(job); assert.equal(job.status,'complete',job.error);
assert.equal(installerDownloads,0,'Update components must use the helper, not download the embedded EXE');
await page.locator('#helper-panel[data-state="running"]').waitFor({timeout:90000});
await page.locator('#helper-update:not([disabled])').waitFor({timeout:30000});
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(),5);
assert.equal(await page.locator('#engine option[value="nvenc"]').isDisabled(),false);
assert.equal(await page.locator('#engine option[value="amf"]').isDisabled(),true);
await page.locator('#auto-update').setChecked(automatic);
await page.locator('.dependencies summary').click();
await page.screenshot({path:path.join(artifacts,'component-updater.png'),animations:'disabled'});
await fs.writeFile(path.join(artifacts,'browser-components.json'),JSON.stringify({updateButton:'passed',installerDownloads,job,automatic},null,2));
console.log('Browser component update passed: real native request, official version check, completed progress, ready tools, preserved automatic-update setting, no EXE download.');
} finally {
await context.close();
if(path.dirname(path.resolve(profile))===path.resolve(os.tmpdir()) && path.basename(profile).startsWith('ytdl-components-test-')) await fs.rm(profile,{recursive:true,force:true});
}
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
import { installedHelperRoot } from "./installed-helper.mjs";
// Uses a fresh browser profile and the registered native host. No browser accounts.
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const extension = path.join(root, "extension");
const artifacts = path.join(root, "test-artifacts");
const folder = path.join(artifacts, "media");
const videoId = process.env.YTDL_TEST_VIDEO_ID || "jNQXAC9IVRw";
assert.match(videoId, /^[\w-]{11}$/);
await fs.mkdir(folder, { recursive: true });
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-browser-test-"));
const context = await chromium.launchPersistentContext(profile, {
headless: true, viewport: { width: 1280, height: 900 },
executablePath: process.env.YTDL_TEST_BROWSER || "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"],
});
try {
const cdp = await context.browser().newBrowserCDPSession();
const installed = await cdp.send("Extensions.loadUnpacked", { path: extension });
assert.equal(installed.id, "ndnoeiaecgmjnjneekaeapeecmmjheli");
const worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker", { timeout: 30000 });
assert.equal(new URL(worker.url()).hostname, "ndnoeiaecgmjnjneekaeapeecmmjheli");
await worker.evaluate(() => chrome.storage.local.set({ onboarding: { state: "complete", version: 1 } }));
// Only the browser page is a fixture; inspection, streams and NVENC are real.
await context.route("https://www.youtube.com/**", route => route.fulfill({
contentType: "text/html", body: '<!doctype html><html dark><head><meta charset="utf-8"></head><body style="margin:30px;background:#0f0f0f;color:white;font:14px Segoe UI"><div style="height:340px;background:#182837;border-radius:15px"></div><h1>Me at the zoo</h1><ytd-watch-metadata><div id="owner" style="display:flex;align-items:center"><div id="subscribe-button"><button style="border:0;border-radius:22px;padding:12px 18px">Подписаться</button></div></div></ytd-watch-metadata></body></html>',
}));
const page = await context.newPage();
const errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto(`https://www.youtube.com/watch?v=${videoId}`);
await page.locator("#ytdl-subscribe-download .settings").click();
const ui = page.frameLocator("#ytdl-menu iframe");
try {
await Promise.race([
ui.locator("#download-form").waitFor({ state: "visible", timeout: 180000 }),
ui.locator('#alert:not([hidden])').waitFor({state:'visible',timeout:180000}).then(async()=>{ throw new Error(await ui.locator('#alert').innerText()); }),
]);
}
catch (error) {
console.log('Connection diagnostics:', await ui.locator('body').innerText());
console.log('Page errors:', errors);
await page.screenshot({path:path.join(artifacts,'browser-native-error.png'),animations:'disabled'});
throw error;
}
assert.match(await ui.locator("#hardware").textContent(), /RTX 5080/);
assert.equal(await ui.locator("#helper-status").textContent(), "Помощник работает");
assert.match(await ui.locator("#helper-message").textContent(), /Автономный комплект/);
assert.equal(await ui.locator('.dependency-row[data-status="installed"]').count(), 5);
await page.evaluate(() => document.documentElement.removeAttribute("dark"));
await ui.locator('html[data-theme="light"]').waitFor();
await page.evaluate(() => document.documentElement.setAttribute("dark", ""));
await ui.locator('html[data-theme="dark"]').waitFor();
const qualities = await ui.locator("#quality option").evaluateAll(nodes => nodes.map(n => ({ id: n.value, text: n.textContent })));
const separate = qualities.filter(q => q.id !== "best" && !q.text.includes("со звуком")).at(-1);
assert.ok(separate, "A real separate video stream must be available");
await ui.locator("#quality").selectOption(separate.id);
await ui.locator("#codec").selectOption("hevc");
await ui.locator(".advanced summary").click();
await ui.locator("#engine").selectOption("nvenc");
await ui.locator("#filename").fill("Browser-native-HEVC");
await ui.locator("#folder").fill(folder);
await ui.locator("#save-settings").click();
try { await page.locator("#ytdl-menu").waitFor({ state: "detached", timeout: 15000 }); }
catch (error) {
console.log("Menu alert:", await ui.locator("#alert").textContent());
console.log("Jobs:", await worker.evaluate(async () => (await chrome.storage.local.get("jobs")).jobs));
await page.screenshot({ path: path.join(artifacts, "browser-native-error.png"), animations: "disabled" });
throw error;
}
await page.locator("#ytdl-subscribe-download .download").click();
await page.locator("#ytdl-notifications .toast").waitFor();
console.log("Real extension connected; download started, on-page toast visible.");
await page.locator("#ytdl-notifications .toast.complete, #ytdl-notifications .toast.error").waitFor({ timeout: 240000 });
const saved = await worker.evaluate(async () => (await chrome.storage.local.get("jobs")).jobs);
const job = Object.values(saved).find(job => job.result?.path && job.url.includes(videoId));
assert.ok(job, JSON.stringify(saved));
assert.equal(job.status, "complete", job.error);
assert.equal(job.result.engine, "nvenc");
assert.equal(job.result.codec, "hevc");
const installedRoot = await installedHelperRoot();
const installedBundle = JSON.parse((await fs.readFile(path.join(installedRoot, "installation.json"), "utf8")).replace(/^\uFEFF/, ""));
const bundle = JSON.parse(await fs.readFile(path.join(installedBundle.current, "bundle.json"), "utf8"));
const probe = await promisify(execFile)(path.join(installedBundle.current, bundle.tools.ffprobe), ["-v", "error", "-show_streams", "-of", "json", job.result.path]);
const streams = JSON.parse(probe.stdout).streams;
assert.ok(streams.some(s => s.codec_type === "video" && s.codec_name === "hevc"));
assert.ok(streams.some(s => s.codec_type === "audio"));
assert.deepEqual(errors, []);
await page.screenshot({ path: path.join(artifacts, "browser-native-complete.png"), animations: "disabled" });
await fs.writeFile(path.join(artifacts, "browser-native.json"), JSON.stringify({ extensionId: new URL(worker.url()).hostname, job }, null, 2));
console.log("Browser/native checks passed: real MV3 extension, native messaging, separate audio, NVENC, saved file, live toast.");
} finally {
await context.close();
const resolved = path.resolve(profile);
if (path.dirname(resolved) === path.resolve(os.tmpdir()) && path.basename(resolved).startsWith("ytdl-browser-test-")) {
await fs.rm(resolved, { recursive: true, force: true });
}
}
+84
View File
@@ -0,0 +1,84 @@
"""Real staged publication, stale-update rejection and live PyPI wheels, no registry changes."""
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
PROJECT = Path(__file__).resolve().parent.parent
def inside(packages=False):
from host.bundle import ROOT, installation_root
from host.updates import update
from host.media import capabilities
application = installation_root()
marker = application / "installation.json"
before = json.loads(marker.read_text(encoding="utf-8-sig"))["current"]
if packages:
import io
import time
from host.main import NativeHost
host = NativeHost(io.BytesIO())
try:
response = host.start_update([{"id": "packages", "version": "latest-compatible"}])
deadline = time.monotonic() + 500
while host.jobs[response["jobId"]]["status"] == "running" and time.monotonic() < deadline: time.sleep(0.1)
job = host.jobs[response["jobId"]]
assert job["status"] == "complete", job
result = job["result"]
finally: host.close()
after = Path(json.loads(marker.read_text(encoding="utf-8-sig"))["current"])
versions = json.loads((after / "bundle.json").read_text(encoding="utf-8"))["pythonPackages"]
assert result["restartRequired"] and "yt-dlp" in {key.lower() for key in versions}
print(json.dumps({"livePyPI": "passed", "nativeWorkerUpdate": "passed", "packages": versions}))
return
archive = PROJECT / ".build-cache/node.zip"
data = archive.read_bytes()
candidate = {"id": "node", "version": "v24.21.0", "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip", "sha256": hashlib.sha256(data).hexdigest()}
class Response:
headers = {"Content-Length": str(len(data))}
def __enter__(self): return self
def __exit__(self, *args): return False
def iter_content(self, count):
for offset in range(0, len(data), count): yield data[offset:offset + count]
dll = ROOT / "runtime/python/python313.dll"
with dll.open("rb") as opened, patch("host.updates.get", return_value=Response()):
result = update(lambda *args: None, lambda _: None, [candidate])
after = json.loads(marker.read_text(encoding="utf-8-sig"))["current"]
assert after != before and result["restartRequired"] and dll.is_file() and opened.read(2) == b"MZ"
try: update(lambda *args: None, lambda _: None, [candidate])
except ValueError as error: assert "другой установкой" in str(error)
else: raise AssertionError("A stale helper must not replace the newer runtime")
try: update(lambda *args: None, lambda _: None, [{**candidate, "sha256": "0" * 64}])
except ValueError as error: assert "Контрольная сумма" in str(error)
else: raise AssertionError("Modified downloads must be rejected")
assert json.loads(marker.read_text(encoding="utf-8-sig"))["current"] == after
assert not list((application / "releases").glob(".ytdl-update-*")) == [], "The published release must remain"
print(json.dumps({"stagedActivation": "passed", "openOldDLL": "preserved", "staleUpdate": "rejected", "badHash": "rejected"}))
def outer():
artifacts = PROJECT / "test-artifacts"; artifacts.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="update-test-", dir=artifacts) as temporary:
root = Path(temporary) / "Helper"
process = subprocess.run([str(PROJECT / "dist/YouTubeDL-Helper-Setup.exe"), "--silent", "--no-register", "--target-root", str(root)], capture_output=True, text=True, encoding="utf-8", timeout=180)
assert process.returncode == 0, process.stdout + process.stderr
records = []
for action in ("--inside", "--inside-packages"):
marker = json.loads((root / "installation.json").read_text(encoding="utf-8-sig"))
python = Path(marker["current"]) / "runtime/python/python.exe"
result = subprocess.run([str(python), "-I", "-X", "utf8", "-B", str(Path(__file__).resolve()), action], capture_output=True, text=True, encoding="utf-8", timeout=600)
assert result.returncode == 0, result.stdout[-1000:] + result.stderr[-4000:]
records.append(json.loads(result.stdout.splitlines()[-1]))
(artifacts / "component-update.json").write_text(json.dumps(records, indent=2), encoding="utf-8")
print("Component updates passed: live PyPI downloads, verified stage activation, retained old DLL, bad hash rejection and stale-update rejection.")
if __name__ == "__main__":
if "--inside" in sys.argv: inside()
elif "--inside-packages" in sys.argv: inside(True)
else: outer()
+73
View File
@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import crypto from "node:crypto";
import { canonicalUrl, bytes, duration, progressText, preferenceOptions, stabilizeDownloadSpeed, checkHelperCompatibility } from "../extension/shared.js";
test("URLs accept watch and Shorts but reject arbitrary origins and credentials", () => {
assert.equal(canonicalUrl("https://www.youtube.com/shorts/jNQXAC9IVRw"), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
assert.equal(canonicalUrl("https://youtu.be/jNQXAC9IVRw?list=123"), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
for (const url of ["https://youtube.com.evil/watch?v=jNQXAC9IVRw", "http://youtube.com/watch?v=jNQXAC9IVRw", "https://user@youtube.com/watch?v=jNQXAC9IVRw"]) assert.equal(canonicalUrl(url), null);
});
test("progress labels distinguish download tracks and GPU conversion", () => {
assert.equal(progressText({ stage: "downloading", track: "audio" }), "Скачивание · аудио");
assert.equal(progressText({ stage: "encoding", engine: "nvenc" }), "Перекодирование · NVIDIA NVENC");
assert.equal(progressText({ stage: "encoding", engine: "amf" }), "Перекодирование · AMD AMF");
assert.equal(progressText({ stage: "encoding", engine: "cpu" }), "Перекодирование · CPU");
assert.equal(progressText({ stage: "merging", engine: "copy" }), "Объединение дорожек · без перекодирования");
assert.equal(progressText({ stage: "remuxing", engine: "copy" }), "Подготовка файла · без перекодирования");
assert.equal(progressText({ stage: "finalizing", engine: "copy", message: "Финализация файла" }), "Финализация файла");
assert.equal(progressText({ stage: "audio", engine: "cpu" }), "Обработка аудио · CPU");
assert.equal(duration(3661), "1:01:01");
assert.equal(duration(61), "1:01");
assert.equal(bytes(0), "");
assert.match(bytes(1048576), /1 МБ/);
});
test("saved quality adapts to another video's formats and never upscales", () => {
const metadata = { videos: [{ id: "new-4k", height: 2160, fps: 60, codec: "av01" }, { id: "new-720", height: 720, fps: 30, codec: "avc1" }],
audios: [{ id: "new-audio", language: "en" }] };
const preference = { folder: "D:\\Videos", videoId: "old-format", audioId: "old-audio", quality: { height: 1080, fps: 30, codec: "H.264" }, audioLanguage: "en", codec: "hevc", engine: "nvenc", videoContainer: "mkv" };
const result = preferenceOptions(metadata, preference);
assert.equal(result.videoId, "new-720"); assert.equal(result.audioId, "new-audio");
assert.equal(result.codec, "hevc"); assert.equal(result.container, "mkv"); assert.equal(result.folder, "D:\\Videos");
assert.equal(preferenceOptions(metadata, { ...preference, videoId: "best" }).videoId, "best");
assert.equal(preferenceOptions(metadata, { ...preference, mode: "audio", audioContainer: "mp3" }).container, "mp3");
});
test("fixed extension ID matches the native host allowed origin", () => {
const manifest = JSON.parse(fs.readFileSync(new URL("../extension/manifest.json", import.meta.url)));
const digest = crypto.createHash("sha256").update(Buffer.from(manifest.key, "base64")).digest().subarray(0, 16).toString("hex");
const id = [...digest].map(character => "abcdefghijklmnop"[parseInt(character, 16)]).join("");
assert.equal(id, "ndnoeiaecgmjnjneekaeapeecmmjheli");
assert.ok(manifest.permissions.includes("nativeMessaging"));
assert.ok(!manifest.host_permissions.includes("<all_urls>"));
});
test("brief missing speed samples do not flicker, expire and reset between tracks", () => {
const sample = { id: "speed", status: "running", stage: "downloading", track: "video", speed: 4.2 * 1048576 };
let previous = stabilizeDownloadSpeed(sample, null, 1000);
for (const now of [1200, 1600, 2000, 2400]) {
previous = stabilizeDownloadSpeed({ ...sample, speed: null }, previous, now);
assert.equal(previous.speed, sample.speed);
assert.equal(previous.speedUpdatedAt, 1000, 'Missing samples must not extend the lifetime of the last measurement');
}
assert.equal(stabilizeDownloadSpeed({ ...sample, speed: null }, previous, 2500).speed, null);
assert.equal(stabilizeDownloadSpeed({ ...sample, speed: 0 }, previous, 1200).speed, 0);
assert.equal(stabilizeDownloadSpeed({ ...sample, track: "audio", speed: null }, previous, 1200).speed, null);
assert.equal(stabilizeDownloadSpeed({ ...sample, stage: "merging", speed: "8x" }, previous, 1200).speedUpdatedAt, null);
});
test("helper compatibility detects outdated, newer and unidentifiable versions in both directions", () => {
assert.equal(checkHelperCompatibility({ version: "1.3.2" }, "1.4.5").compatible, true);
assert.equal(checkHelperCompatibility({ bundle: { version: "1.3.2" } }, "1.4.5").compatible, true);
assert.equal(checkHelperCompatibility({ version: "1.3.2.0" }, "1.4.5").compatible, true);
for (const version of ["1.3.0", "1.2.10", undefined, "unknown"]) {
const result = checkHelperCompatibility({ version }, "1.4.5");
assert.equal(result.compatible, false);
assert.equal(result.update, "helper");
}
for (const version of ["1.3.3", "1.3.20", "2.0.0"]) {
const result = checkHelperCompatibility({ version }, "1.4.5");
assert.equal(result.compatible, false);
assert.equal(result.update, "extension");
}
assert.equal(checkHelperCompatibility({ version: "1.3.2" }, "1.4.2").update, "extension");
assert.equal(checkHelperCompatibility({ version: "1.3.2", minimumExtensionVersion: "1.4.6" }, "1.4.5").update, "extension");
});
+95
View File
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
import { installedHelperRoot } from "./installed-helper.mjs";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
const installedRoot = await installedHelperRoot();
await fs.mkdir(artifacts, { recursive: true });
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-lifecycle-test-"));
const context = await chromium.launchPersistentContext(profile, {
headless: true, acceptDownloads: true,
executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"],
});
try {
const cdp = await context.browser().newBrowserCDPSession();
const { id } = await cdp.send("Extensions.loadUnpacked", { path: path.join(root, "extension") });
const worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker");
const page = await context.newPage();
await page.goto(`chrome-extension://${id}/downloader.html`);
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await page.locator('#setup[open]').waitFor();
await page.locator('#setup-next').click();
await page.locator('#setup-next:not([disabled])').waitFor();
assert.match(await page.locator('#setup-helper-status').textContent(), /компоненты готовы/);
assert.match(await page.locator('#setup-gpu').textContent(), /RTX 5080/);
await page.locator('#setup-next').click();
assert.ok(await page.locator('#setup-folder').inputValue());
await page.locator('#setup-quality').selectOption('1080');
await page.locator('#setup-next').click();
await page.locator('[data-setup-step="3"]').waitFor({state:'visible'});
await page.screenshot({path:path.join(artifacts,'onboarding-native.png'),animations:'disabled'});
await page.locator('#setup-next').click();
await page.locator('#setup').waitFor({state:'hidden'});
const firstRun = await worker.evaluate(() => chrome.storage.local.get(['preferences','onboarding']));
assert.equal(firstRun.onboarding.state,'complete'); assert.equal(firstRun.preferences.quality.height,1080);
await page.reload();
await page.locator('#helper-panel[data-state="running"]').waitFor({timeout:90000});
assert.equal(await page.locator('#setup').evaluate(dialog => dialog.open),false);
await page.locator("#helper-panel > summary").click();
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 5);
await context.route("https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest", route => route.fulfill({ contentType: "text/html", body: "<h1>релизов проекта test destination</h1>" }));
const releaseTab = page.waitForEvent("popup");
await page.locator("#helper-install").click();
const popup = await releaseTab;
await popup.waitForURL("https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest");
assert.equal(await page.locator("#helper-install").getAttribute("download"), null);
await popup.close();
const installerPath = path.join(artifacts, "YouTubeDL-Helper-Test.exe");
await fs.copyFile(path.join(root, "dist/YouTubeDL-Helper-Setup.exe"), installerPath);
const downloaded = await fs.readFile(installerPath);
assert.equal(downloaded.subarray(0, 2).toString(), "MZ");
assert.equal(createHash("sha256").update(downloaded).digest("hex"), createHash("sha256").update(await fs.readFile(path.join(root,"dist/YouTubeDL-Helper-Setup.exe"))).digest("hex"));
console.log("Extension opens the external release page; installer comes from the separate release asset.");
await page.locator("#helper-remove").click();
await page.locator('#helper-panel[data-state="missing"]').waitFor();
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 0);
await page.locator("#helper-start").click();
await page.locator('#helper-panel[data-state="missing"]').waitFor();
const deadline = Date.now() + 40000;
while (Date.now() < deadline) {
try { await fs.access(path.join(installedRoot,"installation.json")); }
catch { break; }
await new Promise(resolve => setTimeout(resolve, 100));
}
await assert.rejects(fs.access(path.join(installedRoot,"installation.json")));
console.log("Uninstall verified; missing helper cannot report Running.");
const installed = await promisify(execFile)("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(root, "tools/run-installer-test.ps1"), "-InstallerPath", installerPath], { timeout: 600000 });
console.log(installed.stdout.trim());
await page.locator("#helper-start").click();
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 5);
assert.equal(await page.locator('.dependency-row button').count(), 0);
assert.match(await page.locator('#helper-message').textContent(), /Автономный комплект/);
assert.match(await page.locator('[data-dependency="python"] .dependency-version').textContent(), /3\.13\./);
const restoredRoot = await installedHelperRoot();
const restoredMarker = JSON.parse((await fs.readFile(path.join(restoredRoot,'installation.json'),'utf8')).replace(/^\uFEFF/,''));
assert.equal(restoredMarker.version,'1.3.0');
await fs.access(path.join(restoredRoot,'YouTubeDL-Manager.exe'));
assert.equal(path.dirname(path.dirname(path.resolve(restoredMarker.current))),path.resolve(restoredRoot),'Manager, marker and registered runtime must share the same physical installation');
if (!await page.locator('#helper-panel').evaluate(panel => panel.open)) await page.locator('#helper-panel > summary').click();
await page.locator('.dependencies summary').click();
await page.screenshot({ path: path.join(artifacts, "helper-components.png"), animations: "disabled" });
await fs.writeFile(path.join(artifacts, "helper-lifecycle.json"), JSON.stringify({ installer: "YouTubeDL-Helper-Setup.exe", externalReleaseLink: "passed", installerSHA256: createHash("sha256").update(downloaded).digest("hex"), firstRun: "passed", firstRunPersistence: "passed", uninstall: "passed", manualStart: "passed", autonomousBundle: "passed" }, null, 2));
console.log("Helper lifecycle passed: external release link, scoped uninstall, missing state, offline installation, manual start and bundled components.");
} finally {
await context.close();
if (path.dirname(path.resolve(profile)) === path.resolve(os.tmpdir()) && path.basename(profile).startsWith("ytdl-lifecycle-test-")) await fs.rm(profile, { recursive: true, force: true });
}
+144
View File
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { randomBytes } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
// Real Edge, real ports, and the bundled installer/helper. Only their host name
// is isolated so testing removal never touches the user's installed helper.
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
const build = path.join(root, ".build");
await fs.mkdir(build, { recursive: true }); await fs.mkdir(artifacts, { recursive: true });
const work = await fs.mkdtemp(path.join(build, "reconnect-"));
const extension = path.join(work, "extension"), helper = path.join(work, "helper");
const name = `com.youtubedl.test_${randomBytes(8).toString("hex")}`;
const run = promisify(execFile);
let manifestPath, context;
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-reconnect-test-"));
const userRegistration = async () => (await run("powershell.exe", ["-NoProfile", "-Command",
"(Get-Item -LiteralPath 'Registry::HKEY_CURRENT_USER\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.youtubedl.native' -ErrorAction SilentlyContinue).GetValue('')"])).stdout.trim();
const beforeRegistration = await userRegistration();
async function register(remove = false) {
await run("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(root, "tests/test-native-registration.ps1"),
"-Name", name, "-ManifestPath", manifestPath, ...(remove ? ["-Remove"] : [])]);
}
async function install() {
const result = await run(path.join(root, "dist/YouTubeDL-Helper-Setup.exe"), ["--silent", "--no-register", "--target-root", helper], { timeout: 180000, windowsHide: true });
assert.match(result.stdout, /100%|Готово/);
const marker = JSON.parse((await fs.readFile(path.join(helper, "installation.json"), "utf8")).replace(/^\uFEFF/, ""));
assert.equal(path.dirname(path.dirname(path.resolve(marker.current))), path.resolve(helper));
const commonPath = path.join(marker.current, "host/common.py");
await fs.writeFile(commonPath, (await fs.readFile(commonPath, "utf8")).replace('HOST_NAME = "com.youtubedl.native"', `HOST_NAME = "${name}"`));
manifestPath = path.join(marker.current, ".runtime/native-host.json");
const manifest = JSON.parse((await fs.readFile(manifestPath, "utf8")).replace(/^\uFEFF/, ""));
manifest.name = name; await fs.writeFile(manifestPath, JSON.stringify(manifest));
await register();
}
try {
await fs.cp(path.join(root, "extension"), extension, { recursive: true });
const sharedPath = path.join(extension, "shared.js");
await fs.writeFile(sharedPath, (await fs.readFile(sharedPath, "utf8")).replace('"com.youtubedl.native"', `"${name}"`));
context = await chromium.launchPersistentContext(profile, {
headless: true, viewport: { width: 520, height: 820 },
executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"],
});
const cdp = await context.browser().newBrowserCDPSession();
const { id } = await cdp.send("Extensions.loadUnpacked", { path: extension });
const worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker");
// Capture real ports to trigger both sides of a disconnect deterministically.
await worker.evaluate(() => {
globalThis.__testUiPorts = []; globalThis.__testNativePorts = [];
chrome.runtime.onConnect.addListener(port => { if (port.name === "ytdl-ui") globalThis.__testUiPorts.push(port); });
const original = chrome.runtime.connectNative.bind(chrome.runtime);
chrome.runtime.connectNative = (...args) => { const port = original(...args); globalThis.__testNativePorts.push(port); return port; };
});
const page = await context.newPage(), errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.addInitScript(() => {
window.__testUiPorts = [];
const original = chrome.runtime.connect.bind(chrome.runtime);
chrome.runtime.connect = (...args) => { const port = original(...args); window.__testUiPorts.push(port); return port; };
window.__pageToken = crypto.randomUUID();
});
await page.goto(`chrome-extension://${id}/downloader.html?theme=dark`);
const token = await page.evaluate(() => window.__pageToken);
await page.locator('#setup[open]').waitFor(); await page.locator('#setup-next').click();
await page.locator('#setup-helper[data-state="missing"]').waitFor();
await worker.evaluate(() => globalThis.__testUiPorts.at(-1).disconnect());
await page.locator('#setup-helper[data-state="offline"]').waitFor();
console.log("Real UI port disconnected while the helper was missing. Installing a private offline bundle.");
await install();
await page.locator('#setup-check').click();
await page.locator('#setup-helper[data-state="running"]').waitFor({ timeout: 90000 });
assert.match(await page.locator('#setup-helper-message').textContent(), /5\/5/);
// Chrome does not signal onDisconnect to the end that called disconnect().
// This reproduces the exact synchronous 'disconnected port object' race.
await page.evaluate(() => window.__testUiPorts.at(-1).disconnect());
await page.locator('#setup-check').click();
await page.locator('#setup-check:not([disabled])').waitFor();
assert.equal(await page.locator('#setup-helper').getAttribute('data-state'), 'running');
assert.equal(await page.evaluate(() => window.__pageToken), token);
await page.screenshot({ path: path.join(artifacts, "helper-reconnect-setup.png") });
console.log("Wizard Check passed after installation and both real port-disconnect cases, without page reload.");
await page.locator('#setup-skip').click();
await page.locator('#helper-panel').evaluate(panel => { panel.open = true; });
const other = await context.newPage(); other.on('pageerror', error => errors.push(error.message));
await other.goto(`chrome-extension://${id}/downloader.html?theme=dark`);
await other.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await worker.evaluate(() => globalThis.__testUiPorts.forEach(port => { try { port.disconnect(); } catch {} }));
await page.locator('#helper-panel[data-state="offline"]').waitFor();
await page.locator('#helper-start').click();
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await other.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await page.locator('#helper-remove').click();
await page.locator('#helper-panel[data-state="missing"]').waitFor();
await other.locator('#helper-panel[data-state="missing"]').waitFor();
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 0);
await page.screenshot({ path: path.join(artifacts, "helper-reconnect-removed.png") });
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
try { await fs.access(path.join(helper, 'installation.json')); }
catch { break; }
await new Promise(resolve => setTimeout(resolve, 250));
}
await assert.rejects(fs.access(path.join(helper, 'installation.json')));
console.log("Start and actual helper removal passed; both open menus report Missing immediately.");
await install();
await page.locator('#helper-start').click();
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await other.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
// External registration removal and native-port loss must be discovered by polling.
await register(true);
await worker.evaluate(() => globalThis.__testNativePorts.at(-1).disconnect());
await page.locator('#helper-panel[data-state="missing"]').waitFor({ timeout: 25000 });
await other.locator('#helper-panel[data-state="missing"]').waitFor({ timeout: 25000 });
await register();
await page.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
await other.locator('#helper-panel[data-state="running"]').waitFor({ timeout: 90000 });
assert.equal(await page.evaluate(() => window.__pageToken), token);
assert.equal(await page.locator('.dependency-row[data-status="installed"]').count(), 5);
assert.equal(await page.locator('#alert').isVisible(), false);
assert.deepEqual(errors, []);
assert.equal(await userRegistration(), beforeRegistration);
await page.screenshot({ path: path.join(artifacts, "helper-reconnect-ready.png") });
await fs.writeFile(path.join(artifacts, "helper-reconnect-result.json"), JSON.stringify({
version: JSON.parse(await fs.readFile(path.join(root,'extension/manifest.json'))).version,
actualEdge: true, actualInstaller: true, actualNativePorts: true, checkAfterInstall: true,
startAfterDisconnect: true, removalUpdatesAllMenus: true, reinstall: true,
pollingDetectsExternalChanges: true, noPageReload: true, userRegistrationUnchanged: true,
}, null, 2));
console.log("Real helper reconnect passed: Check, Start, removal, reinstall, external changes, two open menus, no page reload.");
} finally {
if (context) await context.close();
if (manifestPath) await register(true);
assert.equal(await userRegistration(), beforeRegistration);
if (path.dirname(path.resolve(profile)) === path.resolve(os.tmpdir()) && path.basename(profile).startsWith("ytdl-reconnect-test-")) await fs.rm(profile, { recursive: true, force: true, maxRetries: 20, retryDelay: 200 });
if (path.dirname(path.resolve(work)) === path.resolve(build) && path.basename(work).startsWith("reconnect-")) await fs.rm(work, { recursive: true, force: true, maxRetries: 20, retryDelay: 200 });
}
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-history-test-"));
const context = await chromium.launchPersistentContext(profile, { headless: true, viewport: { width: 1180, height: 900 },
executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", reducedMotion: "reduce",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"] });
const errors = [];
context.on("page", page => page.on("pageerror", error => errors.push(error.message)));
const videoUrl = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
const records = {
saved: { id: "saved", title: "Subnautica · Путешествие в глубину", kind: "download", status: "complete", stage: "complete", createdAt: 1791620000,
url: videoUrl, result: { filename: "Путешествие в глубину.mp4", path: "D:\\Videos\\Путешествие в глубину.mp4", size: 512 * 1048576 } },
cancelled: { id: "cancelled", title: "Отменённое скачивание", kind: "download", status: "cancelled", createdAt: 1791610000 },
error: { id: "error", title: "Не удалось скачать видео", kind: "download", status: "error", error: "YouTube запросил подтверждение входа. Подключите вход из текущего профиля браузера в настройках.", url: videoUrl, createdAt: 1791630000 },
updateError: { id: "update-error", title: "Обновление компонентов", kind: "update", status: "error", error: "Не удалось получить обновление FFmpeg. Проверьте соединение и повторите попытку.", createdAt: 1791600000 },
unsafe: { id: "unsafe", title: '<img src=x onerror="alert(1)">', status: "error", url: "https://evil.test/", error: 'Ошибка <script>alert(1)</script>\n' + "Длинное сообщение. ".repeat(30), createdAt: 1791590000 },
updateComplete: { id: "update-complete", title: "Обновление компонентов", kind: "update", status: "complete", createdAt: 1791580000 },
};
try {
const cdp = await context.browser().newBrowserCDPSession();
const { id } = await cdp.send("Extensions.loadUnpacked", { path: path.join(root, "extension") });
let worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker");
await worker.evaluate(async jobs => { await chrome.storage.local.set({ jobs, theme: "dark" }); }, records);
const reloaded = context.waitForEvent("serviceworker", { predicate: next => next !== worker, timeout: 20000 });
await worker.evaluate(() => { setTimeout(() => chrome.runtime.reload(), 50); });
worker = await reloaded;
const page = await context.newPage();
await page.goto(`chrome-extension://${id}/history.html`);
await page.locator('#downloads-list [data-job-id="saved"]').waitFor();
await page.waitForTimeout(100);
assert.equal(await page.locator('#downloads-list .history-entry').count(), 2);
assert.equal(await page.locator('#downloads-count').textContent(), "2");
assert.equal(await page.locator('#errors-count').textContent(), "3");
assert.match(await page.locator('[data-job-id="saved"] .entry-meta').textContent(), /512 МБ/);
assert.match(await page.locator('[data-job-id="saved"] .entry-path').textContent(), /D:\\Videos/);
assert.equal(await page.locator('[data-job-id="saved"] a').getAttribute("href"), videoUrl);
assert.equal(await page.locator('#errors-panel').isVisible(), false);
assert.equal(await page.locator('#history-connection').isVisible(), false, "The real history port must be accepted by the service worker");
await page.screenshot({ path: path.join(artifacts, "history-downloads-dark.png") });
await page.locator('#errors-tab').click();
assert.equal(await page.locator('#downloads-panel').isVisible(), false);
assert.equal(await page.locator('#errors-list .history-entry').count(), 3);
assert.equal(await page.locator('[data-job-id="unsafe"] img, [data-job-id="unsafe"] script, [data-job-id="unsafe"] a').count(), 0, "Titles/errors are text, and unsafe URLs have no link");
await page.screenshot({ path: path.join(artifacts, "history-errors-dark.png") });
await page.locator('#errors-tab').press('ArrowLeft');
assert.equal(await page.locator('#downloads-tab').getAttribute('aria-selected'), "true");
assert.equal(await page.locator('#downloads-tab').evaluate(node => node === document.activeElement), true);
// An actual extension UI port must create an actual browser tab.
const [opened] = await Promise.all([context.waitForEvent("page"), page.evaluate(async () => {
const { createUiConnection } = await import(chrome.runtime.getURL("ui-connection.js"));
const { call } = createUiConnection({}); await call("open_history", { theme: "dark" });
})]);
await opened.waitForURL(`chrome-extension://${id}/history.html?theme=dark`);
await opened.locator('#downloads-list .history-entry').first().waitFor();
await opened.close();
await page.setViewportSize({ width: 360, height: 800 });
await page.locator('#theme').click(); // dark -> auto
await page.locator('#theme').click(); // auto -> light
assert.equal(await page.locator('html').getAttribute('data-theme'), "light");
await page.locator('#errors-tab').click();
assert.equal(await page.locator('html').evaluate(node => node.scrollWidth > node.clientWidth), false, "History and long errors must fit a narrow viewport");
await page.screenshot({ path: path.join(artifacts, "history-errors-light-360.png"), fullPage: true });
await page.reload();
assert.equal(await page.locator('html').getAttribute('data-theme'), "light");
await page.locator('#downloads-list [data-job-id="saved"]').waitFor();
assert.equal(await page.locator('#downloads-count').textContent(), "2", "Stored records survive page reload");
// Browser fixture covers arriving terminal events without starting a real download.
await context.route("https://ui.ytdl.test/**", async route => {
const name = new URL(route.request().url()).pathname.slice(1);
if (!["history.html", "history.js", "history.css", "styles.css", "theme-init.js", "shared.js", "ui-connection.js"].includes(name)) return route.abort();
await route.fulfill({ body: await fs.readFile(path.join(root, "extension", name)), contentType: name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript" });
});
const fixture = await context.newPage();
await fixture.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await fixture.goto("https://ui.ytdl.test/history.html");
await fixture.locator('#downloads-empty').waitFor();
await fixture.evaluate(records => { window.__emitJob(records.saved); }, records);
await fixture.locator('#downloads-list [data-job-id="saved"]').waitFor();
await fixture.locator('#errors-tab').click();
await fixture.evaluate(records => { window.__emitJob(records.error); }, records);
await fixture.locator('#errors-list [data-job-id="error"]').waitFor();
assert.equal(await fixture.locator('#errors-tab').getAttribute('aria-selected'), "true", "New records must preserve the chosen tab");
await fixture.evaluate(() => window.__emitJob({ id: "active", kind: "download", status: "running", stage: "downloading", createdAt: 1791650000 }));
assert.equal(await fixture.locator('#downloads-count').textContent(), "1");
assert.equal(await fixture.locator('#errors-count').textContent(), "1");
await fixture.evaluate(records => {
window.__uiDisconnect();
window.__fixtureStorage.jobs[records.updateError.id] = records.updateError;
}, records);
await fixture.waitForFunction(() => document.getElementById('errors-count').textContent === "2");
assert.equal(await fixture.locator('#history-connection').isVisible(), false, "History reconnects and re-reads local records without reload");
assert.deepEqual(errors, []);
console.log("History passed in actual Edge extension: persistent records, separate tabs, real new-tab action, safe text, themes, keyboard, 360px layout and live completion/error updates.");
} finally {
await context.close();
if (path.dirname(path.resolve(profile)) === path.resolve(os.tmpdir()) && path.basename(profile).startsWith("ytdl-history-test-")) await fs.rm(profile, { recursive: true, force: true });
}
+59
View File
@@ -0,0 +1,59 @@
import test from "node:test";
import assert from "node:assert/strict";
test("history connects without a helper, opens a tab, and retains failures before download starts", async () => {
const url = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
const storage = { jobs: {
saved: { id: "saved", title: "Сохранённое видео", kind: "download", status: "complete", result: { path: "D:/Videos/saved.mp4" }, createdAt: 1 },
interrupted: { id: "interrupted", status: "running", createdAt: 2 },
} };
const listeners = {}, tabs = [], nativeRequests = [];
const noEvent = { addListener() {} };
const runtime = { id: "history-test", getManifest: () => ({ version: "1.4.5" }), getURL: file => `chrome-extension://history-test/${file}`,
onConnect: { addListener: listener => { listeners.connect = listener; } }, onMessage: noEvent,
connectNative() {
let receive;
return { onMessage: { addListener: listener => { receive = listener; } }, onDisconnect: noEvent, disconnect() {},
postMessage(message) {
nativeRequests.push(message);
queueMicrotask(() => receive({ id: message.id, ok: message.action !== "inspect", result: message.action === "hello" ? { version: "1.3.2" } : { alive: true }, error: "Видео недоступно" }));
} };
} };
globalThis.chrome = { runtime, storage: { local: { get: async () => structuredClone(storage), set: async data => Object.assign(storage, structuredClone(data)) } },
tabs: { create: async tab => { tabs.push(tab); }, sendMessage: async () => {} },
notifications: { clear: async () => {}, create: async () => {}, update: async () => true, onButtonClicked: noEvent },
permissions: { contains: async () => false },
action: { onClicked: noEvent, setBadgeText() {}, setBadgeBackgroundColor() {} },
};
await import("../extension/background.js?history-test");
const attach = file => {
let send;
const events = [], pending = new Map();
listeners.connect({ name: "ytdl-ui", sender: { id: runtime.id, url: runtime.getURL(file) }, onDisconnect: noEvent,
onMessage: { addListener: listener => { send = listener; } },
postMessage(message) {
if (message.id) { pending.get(message.id)?.(message); pending.delete(message.id); }
else events.push(message);
} });
return { connected: () => Boolean(send), events, call: (action, payload = {}) => new Promise(resolve => {
const id = crypto.randomUUID(); pending.set(id, resolve); send({ id, action, payload });
}) };
};
const history = attach("history.html?theme=dark"), menu = attach("downloader.html?embedded=1");
assert.ok(history.connected());
assert.ok(!attach("history.html.evil").connected());
assert.ok(!attach("content.js").connected());
const state = (await history.call("state")).result;
assert.equal(state.jobs.length, 2);
assert.equal(state.jobs.find(job => job.id === "interrupted").status, "error");
assert.equal(nativeRequests.length, 0, "Viewing history must not launch or query the helper");
assert.deepEqual((await menu.call("open_history", { theme: "dark" })).result, { opened: true });
assert.deepEqual(tabs, [{ url: runtime.getURL("history.html?theme=dark") }]);
const inspection = await menu.call("inspect", { url });
assert.equal(inspection.ok, false);
assert.equal(inspection.error, "Видео недоступно");
const failure = Object.values(storage.jobs).find(job => job.kind === "inspection");
assert.equal(failure.error, "Видео недоступно"); assert.equal(failure.url, url);
assert.ok(history.events.some(message => message.job?.id === failure.id), "Already open history receives failures immediately");
assert.ok(!JSON.stringify(storage).includes("browserCookies"));
});
+23
View File
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { HOST_NAME } from "../extension/shared.js";
export async function installedHelperRoot() {
if (process.env.YTDL_INSTALLED_ROOT) return process.env.YTDL_INSTALLED_ROOT;
// A packaged parent process can redirect AppData. Use the registration that
// Edge actually opens, rather than a potentially stale LOCALAPPDATA directory.
assert.match(HOST_NAME, /^[a-z.]+$/);
const command = `$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); (Get-Item -LiteralPath 'Registry::HKEY_CURRENT_USER\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${HOST_NAME}').GetValue('')`;
const registration = (await promisify(execFile)("powershell.exe", ["-NoProfile", "-Command", command])).stdout.trim();
const native = JSON.parse((await fs.readFile(registration, "utf8")).replace(/^\uFEFF/, ""));
assert.equal(native.name, HOST_NAME);
const release = path.dirname(path.dirname(native.path));
const bundle = JSON.parse(await fs.readFile(path.join(release, "bundle.json"), "utf8"));
assert.equal(bundle.product, "YouTubeDL.Helper");
assert.equal(bundle.mode, "autonomous");
assert.equal(path.basename(path.dirname(release)), "releases");
return path.dirname(path.dirname(release));
}
+166
View File
@@ -0,0 +1,166 @@
"""Real FFmpeg + native messaging checks. Add --online for a small public YouTube download."""
from __future__ import annotations
import argparse
import contextlib
import io
import json
import queue
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from host.common import EXTENSION_ID
from host.media import NO_WINDOW, capabilities, config, probe, tool
from host.protocol import read_message, write_message
from host.worker import convert
class NativeSession:
def __init__(self):
launcher = ROOT / ".runtime" / "launch-host.cmd"
self.process = subprocess.Popen([str(launcher), f"chrome-extension://{EXTENSION_ID}/"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=NO_WINDOW)
self.messages = queue.Queue()
self.jobs = {}
self.errors = []
def read():
try:
while (message := read_message(self.process.stdout)) is not None:
self.messages.put(message)
except Exception as error:
self.messages.put({"protocolError": str(error)})
threading.Thread(target=read, daemon=True).start()
threading.Thread(target=lambda: self.errors.append(self.process.stderr.read().decode("utf-8", "replace")), daemon=True).start()
def send(self, action, payload=None):
token = str(uuid.uuid4())
write_message(self.process.stdin, {"id": token, "action": action, "payload": payload or {}})
return token
def next(self, timeout=150):
message = self.messages.get(timeout=timeout)
if "protocolError" in message:
raise AssertionError(message)
if message.get("event") == "job":
self.jobs[message["job"]["id"]] = message["job"]
return message
def call(self, action, payload=None):
token = self.send(action, payload)
while True:
message = self.next()
if message.get("id") == token:
if not message["ok"]:
raise AssertionError(message["error"])
return message["result"]
def download(self, payload):
result = self.call("download", payload)
job_id = result["jobId"]
stage = None
deadline = time.monotonic() + 240
while time.monotonic() < deadline:
job = self.jobs.get(job_id, {})
if job.get("status") == "complete": return job["result"]
if job.get("status") in {"error", "cancelled"}: raise AssertionError(job)
self.next()
job = self.jobs.get(job_id, {})
if job.get("stage") != stage:
stage = job.get("stage"); print(f"Native job: {stage}", flush=True)
raise TimeoutError("Native job timeout")
def close(self):
self.process.stdin.close()
self.process.wait(timeout=30)
if self.process.returncode:
raise AssertionError("Native host exit: " + "".join(self.errors))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--online", action="store_true")
args = parser.parse_args()
artifacts = ROOT / "test-artifacts"
artifacts.mkdir(exist_ok=True)
media_dir = artifacts / "media"
media_dir.mkdir(exist_ok=True)
hardware = capabilities()
synthetic = media_dir / "input-vp9-opus.webm"
subprocess.run([tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=s=640x360:r=30",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "0.6", "-c:v", "libvpx-vp9",
"-cpu-used", "8", "-c:a", "libopus", str(synthetic)], check=True, creationflags=NO_WINDOW)
summary = {"gpu": hardware["gpu"], "nvenc": hardware["nvenc"], "formats": [], "native": {}}
defaults = {"mode": "video", "container": "mp4", "codec": "h264", "engine": "nvenc", "preset": "balanced", "bitrate": 256}
source_probe = probe(synthetic)
for codec in ("h264", "hevc", "av1"):
target = media_dir / f"gpu-{codec}.mp4"
with contextlib.redirect_stdout(io.StringIO()):
plan = convert(synthetic, target, {**defaults, "codec": codec}, source_probe, hardware["nvenc"])
streams = probe(target)["streams"]
assert plan["engine"] == "nvenc"
assert {s["codec_type"] for s in streams} >= {"video", "audio"}
assert next(s for s in streams if s["codec_type"] == "video")["codec_name"] == codec
summary["formats"].append({"format": codec, "engine": plan["engine"], "size": target.stat().st_size})
for container in ("m4a", "mp3", "opus", "flac", "wav"):
target = media_dir / f"audio.{container}"
with contextlib.redirect_stdout(io.StringIO()):
convert(synthetic, target, {**defaults, "mode": "audio", "container": container}, source_probe, hardware["nvenc"])
assert {s["codec_type"] for s in probe(target)["streams"]} == {"audio"}
summary["formats"].append({"format": container, "size": target.stat().st_size})
target = media_dir / "video-only.mkv"
with contextlib.redirect_stdout(io.StringIO()):
plan = convert(synthetic, target, {**defaults, "mode": "video_only", "container": "mkv", "codec": "auto"}, source_probe, hardware["nvenc"])
assert plan["engine"] == "copy"
assert {s["codec_type"] for s in probe(target)["streams"]} == {"video"}
with_session = NativeSession()
try:
hello = with_session.call("hello")
summary["native"]["hello"] = hello["gpu"]
if args.online:
url = "https://www.youtube.com/watch?v=jNQXAC9IVRw"
info = with_session.call("inspect", {"url": url})
(artifacts / "sample-metadata.json").write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
separated = [f for f in info["videos"] if not f["hasAudio"]]
assert separated and info["audios"], "Separate tracks required for this check"
chosen = sorted(separated, key=lambda f: (f["height"], f["fps"]))[0]
payload = {**defaults, "url": url, "folder": str(media_dir), "videoId": chosen["id"], "audioId": "best", "filename": "YouTube-test-HEVC", "codec": "hevc"}
result = with_session.download(payload)
downloaded = probe(result["path"])
assert {s["codec_type"] for s in downloaded["streams"]} >= {"video", "audio"}
assert result["engine"] == "nvenc"
summary["native"]["videoPlusAudio"] = result
result = with_session.download({**payload, "mode": "audio", "container": "mp3", "filename": "YouTube-test-audio"})
assert {s["codec_type"] for s in probe(result["path"])["streams"]} == {"audio"}
summary["native"]["audioOnly"] = result
result = with_session.download({**payload, "mode": "video_only", "container": "mkv", "codec": "copy", "filename": "YouTube-test-video-only"})
assert {s["codec_type"] for s in probe(result["path"])["streams"]} == {"video"}
summary["native"]["videoOnly"] = result
long_url = "https://www.youtube.com/watch?v=aircAruvnKk"
cancel_payload = {**payload, "url": long_url, "videoId": "best", "filename": "cancelled-test"}
job_id = with_session.call("download", cancel_payload)["jobId"]
while True:
message = with_session.next()
job = with_session.jobs.get(job_id, {})
if job.get("stage") == "downloading": break
if job.get("status") == "error": raise AssertionError(job)
with_session.call("cancel", {"jobId": job_id})
deadline = time.monotonic() + 8
while time.monotonic() < deadline and list(media_dir.glob(".ytdl-*")):
time.sleep(0.1)
assert not list(media_dir.glob(".ytdl-*")), "Cancelled job left temporary files"
assert not list(media_dir.glob("cancelled-test.*")), "Cancelled file was published"
summary["native"]["cancel"] = "passed; process tree and temporary files removed"
finally:
with_session.close()
(artifacts / "verification.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print("Integration checks passed.", flush=True)
if __name__ == "__main__": main()
+89
View File
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
// This test loads the real YouTube website. No routes or page fixtures.
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, 'test-artifacts');
await fs.mkdir(artifacts,{recursive:true});
const profile = await fs.mkdtemp(path.join(os.tmpdir(),'ytdl-live-shorts-'));
const context = await chromium.launchPersistentContext(profile,{
headless:true, viewport:{width:2160,height:1100},
executablePath:'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
ignoreDefaultArgs:['--disable-extensions'], args:['--enable-unsafe-extension-debugging','--mute-audio'],
});
const cards = 'ytm-shorts-lockup-view-model, yt-shorts-lockup-view-model, ytd-reel-item-renderer';
try {
const cdp = await context.browser().newBrowserCDPSession();
const {id} = await cdp.send('Extensions.loadUnpacked',{path:path.join(root,'extension')});
const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
await worker.evaluate(() => chrome.storage.local.set({onboarding:{state:'complete',version:1}}));
const page = await context.newPage();
const errors=[]; page.on('pageerror',error => errors.push(error.message));
console.log('Loading real YouTube search in Edge with the actual extension.');
await page.goto(process.env.YTDL_LIVE_URL || 'https://www.youtube.com/results?search_query=subnautica+shorts',{waitUntil:'domcontentloaded',timeout:60000});
console.log('YouTube document loaded:',page.url());
const consent=page.getByRole('button',{name:/Reject all|Отклонить все/i}).first();
if(await consent.isVisible()) await consent.click();
await page.locator(cards).first().waitFor({timeout:60000});
const first=page.locator(cards).first(); await first.scrollIntoViewIfNeeded();
await page.waitForTimeout(1000);
const snapshot=()=>page.evaluate(selector => {
const box=node=>node ? {...node.getBoundingClientRect().toJSON()} : null;
const inspect=node=>({tag:node.tagName,class:node.className,display:getComputedStyle(node).display,
overflowX:getComputedStyle(node).overflowX,overflowY:getComputedStyle(node).overflowY,rect:box(node),offsetWidth:node.offsetWidth});
return {url:location.href,viewport:{width:innerWidth,height:innerHeight},cards:[...document.querySelectorAll(selector)].slice(0,6).map(card=>{
const host=document.querySelector(`[data-ytdl-preview-for="${card.dataset.ytdlPreview}"]`);
let ancestors=[];for(let element=card; element;element=element.parentElement)ancestors.push(inspect(element));
return {html:card.outerHTML.slice(0,16000),media:[...card.querySelectorAll('img,video')].map(inspect),ancestors,control:host ? {style:host.getAttribute('style'),rect:box(host),buttons:[...host.shadowRoot.querySelectorAll('button')].map(inspect)} : null};
}),videos:[...document.querySelectorAll('video')].map(video=>({time:video.currentTime,paused:video.paused,rect:box(video)}))};
},cards);
const before=await snapshot();
await fs.writeFile(path.join(artifacts,'live-shorts-before.json'),JSON.stringify(before,null,2));
await page.screenshot({path:path.join(artifacts,'live-shorts-before.png')});
console.log('Actual Shorts cards:',before.cards.length,'Controls:',before.cards.map(card=>card.control?.style || 'missing'));
if(process.env.YTDL_LIVE_DIAGNOSTIC==='1') {
console.log('Live DOM diagnostics saved.');
} else {
const key=await first.getAttribute('data-ytdl-preview');
assert.ok(key,'Real Shorts card must have extension controls');
const host=page.locator(`[data-ytdl-preview-for="${key}"]`);
await host.locator('.download').waitFor({state:'visible',timeout:15000});
const thumbnail=first.locator('a').filter({has:first.locator('img')}).first();
const target=await thumbnail.count() ? thumbnail : first;
await target.hover();
await page.waitForFunction(() => [...document.querySelectorAll('video')].some(video =>
!video.paused && video.currentTime > 1 && video.getBoundingClientRect().width > 0),{},{timeout:20000});
const after=await snapshot();
await fs.writeFile(path.join(artifacts,'live-shorts-hover.json'),JSON.stringify(after,null,2));
await page.screenshot({path:path.join(artifacts,'live-shorts-hover.png')});
const shelf=first.locator('xpath=ancestor::*[self::grid-shelf-view-model or self::ytd-rich-shelf-renderer or self::ytd-reel-shelf-renderer][1]');
if(await shelf.count()) await shelf.screenshot({path:path.join(artifacts,'live-shorts-proof.png')});
assert.ok(after.videos.some(video=>!video.paused && video.time>1 && video.rect.width>0),'Real YouTube preview video must actually play');
await host.locator('.download').waitFor({state:'visible'});
assert.equal(await host.locator('.settings').isVisible(),true);
const hit=await host.evaluate(node=>{const r=node.getBoundingClientRect();return document.elementFromPoint(r.left+17,r.top+17)===node;});
assert.equal(hit,true,'Real hover player must not cover the download button');
await host.locator('.settings').click();
await page.locator('#ytdl-menu iframe').waitFor({state:'visible'});
const menu=page.frameLocator('#ytdl-menu iframe');
await menu.locator('#helper-panel:not([data-state="checking"]):not([data-state="idle"])').waitFor({timeout:90000});
const helperStatus=await menu.locator('#helper-panel').getAttribute('data-state');
if(helperStatus==='running') assert.equal(await menu.locator('.dependency-row[data-status="installed"]').count(),5);
else assert.equal(await menu.locator('.dependency-row[data-status="installed"]').count(),0,'Unavailable helper must not retain ready component indicators');
assert.ok(!/disconnected port object/i.test(await menu.locator('#helper-message').textContent()));
console.log('Embedded menu actual helper state:',helperStatus,await menu.locator('#helper-message').textContent());
await fs.writeFile(path.join(artifacts,'live-shorts-result.json'),JSON.stringify({
url:page.url(),extensionId:id,version:JSON.parse(await fs.readFile(path.join(root,'extension','manifest.json'))).version,
actualPreviewPlaying:true,controlsVisible:true,downloadHitTest:true,settingsOpened:true,embeddedConnectionModuleLoaded:true,embeddedHelperStatus:helperStatus,
},null,2));
console.log('Live Shorts passed: actual preview playback, visible controls before/after hover, hit testing, settings click and actual helper state in the embedded menu.');
}
await fs.writeFile(path.join(artifacts,'live-shorts-errors.json'),JSON.stringify(errors,null,2));
} finally {
await context.close();
if(path.dirname(path.resolve(profile))===path.resolve(os.tmpdir()) && path.basename(profile).startsWith('ytdl-live-shorts-')) await fs.rm(profile,{recursive:true,force:true});
}
+112
View File
@@ -0,0 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
test("native lifecycle recovers stale ports and publishes install/remove/update state to all menus", async () => {
const ports = [], listeners = {}, storage = {}, pending = new Map(), updates = [];
let installed = false;
let helperVersion = "1.3.2";
let componentsReady = true;
const noEvent = { addListener() {} };
const runtime = { id: "test-id", getManifest: () => ({ version: "1.4.5" }), getURL: name => `chrome-extension://test-id/${name}`,
onConnect: { addListener: listener => { listeners.connect = listener; } }, onMessage: noEvent,
connectNative() {
let receive, disconnected;
const port = { sent: [], dead: false,
onMessage: { addListener: listener => { receive = listener; } },
onDisconnect: { addListener: listener => { disconnected = listener; } },
disconnect() { this.dead = true; queueMicrotask(() => disconnected()); },
emit: message => receive(message),
postMessage(message) {
if (this.dead) throw new Error("Attempting to use a disconnected port object");
this.sent.push(message);
if (!installed) {
queueMicrotask(() => { this.dead = true; runtime.lastError = { message: "Specified native messaging host not found." }; disconnected(); delete runtime.lastError; });
return;
}
const result = message.action === "hello" ? { version: helperVersion, ready: componentsReady, gpu: "Test GPU" }
: message.action === "ping" ? { alive: true }
: message.action === "helper_uninstall" ? { removed: true }
: message.action === "helper_update" ? { jobId: "update-job" } : {};
if (message.action === "helper_uninstall") installed = false;
queueMicrotask(() => receive({ id: message.id, ok: true, result }));
},
};
ports.push(port); return port;
},
};
globalThis.chrome = { runtime,
storage: { local: { get: async () => storage, set: async values => Object.assign(storage, values) } },
notifications: { clear: async () => {}, create: async () => {}, update: async () => true, onButtonClicked: noEvent },
tabs: { sendMessage: async () => {} },
action: { onClicked: noEvent, setBadgeText() {}, setBadgeBackgroundColor() {} },
};
await import("../extension/background.js?native-lifecycle-test");
let send;
const attach = collect => listeners.connect({ name: "ytdl-ui", sender: { id: runtime.id, url: runtime.getURL("downloader.html") },
onDisconnect: noEvent, onMessage: { addListener: listener => { send = listener; } },
postMessage(message) {
if (message.id) { pending.get(message.id)?.(message); pending.delete(message.id); }
else collect.push(message);
},
});
const otherMenu = []; attach(otherMenu); attach(updates);
const call = (action, payload = {}) => new Promise(resolve => {
const id = crypto.randomUUID(); pending.set(id, resolve); send({ id, action, payload });
});
assert.equal((await call("helper_start")).ok, false);
assert.equal(updates.at(-1).helper.status, "missing");
installed = true;
assert.equal((await call("helper_status")).result.status, "running", "Polling discovers a newly installed helper");
ports.at(-1).dead = true;
assert.equal((await call("helper_start")).ok, true, "One click must recover a stale native port before its disconnect callback");
const fresh = ports.at(-1);
const speedJob = { id: "speed-dropout", status: "running", stage: "downloading", track: "video", speed: 4.2 * 1048576 };
fresh.emit({ event: "job", job: speedJob });
await new Promise(resolve => setImmediate(resolve));
fresh.emit({ event: "job", job: { ...speedJob, speed: null } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(updates.filter(message => message.job?.id === speedJob.id).at(-1).job.speed, speedJob.speed, "Native progress normalizes a missing speed before publishing to menus and notifications");
fresh.emit({ event: "job", job: { ...speedJob, track: "audio", speed: null } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(updates.filter(message => message.job?.id === speedJob.id).at(-1).job.speed, null, "A new audio track must not inherit the video's speed");
fresh.emit({ event: "job", job: { ...speedJob, status: "cancelled", stage: "cancelled", speed: null } });
await new Promise(resolve => setImmediate(resolve));
ports.at(-2).disconnect(); await new Promise(resolve => setImmediate(resolve));
assert.equal((await call("helper_status")).result.status, "running", "Old disconnect must not invalidate the replacement");
assert.equal((await call("helper_remove")).ok, true);
assert.equal(updates.at(-1).helper.status, "missing");
assert.equal(otherMenu.at(-1).helper.status, "missing", "Removal updates every open menu");
assert.equal((await call("helper_status")).result.status, "missing");
installed = true;
assert.equal((await call("helper_start")).ok, true);
await call("helper_update", { title: "Обновление компонентов" });
const updatingPort = ports.at(-1);
updatingPort.emit({ event: "job", job: { id: "update-job", kind: "update", status: "complete", stage: "complete", result: { restartRequired: true } } });
await new Promise(resolve => setTimeout(resolve, 20));
assert.notEqual(ports.at(-1), updatingPort);
assert.equal((await call("helper_status")).result.status, "running", "Component update reconnects and republishes measured capabilities");
componentsReady = false;
ports.at(-1).emit({ event: "job", job: { id: "broken-component", kind: "dependency", status: "error", stage: "error", error: "Компонент недоступен" } });
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal((await call("state")).result.helper.hardware.ready, false, "A failed component operation refreshes readiness");
componentsReady = true;
ports.at(-1).emit({ event: "job", job: { id: "restored-component", kind: "dependency", status: "complete", stage: "complete" } });
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal((await call("state")).result.helper.hardware.ready, true, "Restoring a component refreshes readiness without reload");
assert.ok(fresh.sent.some(message => message.action === "helper_uninstall"));
for (const [version, update] of [["1.3.0", "helper"], ["1.3.3", "extension"]]) {
helperVersion = version;
ports.at(-1).disconnect();
await new Promise(resolve => setImmediate(resolve));
assert.equal((await call("helper_start")).ok, false);
const state = (await call("state")).result.helper;
assert.equal(state.status, "incompatible");
assert.equal(state.compatibility.update, update);
assert.equal(state.hardware.version, version);
const port = ports.at(-1);
assert.equal((await call("download", { url: "https://youtu.be/jNQXAC9IVRw" })).ok, false, 'Incompatible versions cannot launch a download');
assert.ok(!port.sent.some(message => message.action === "download"));
}
helperVersion = "1.3.2";
assert.equal((await call("helper_status")).result.status, "running", 'Version repair is detected by the Check button without reloading');
});
+97
View File
@@ -0,0 +1,97 @@
"""Exercise the real offline installer with unusable external tools and proxies."""
import ctypes
import json
import os
import subprocess
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS = ROOT / "test-artifacts"
ARTIFACTS.mkdir(exist_ok=True)
INSTALLER = ROOT / "dist/YouTubeDL-Helper-Setup.exe"
environment = {**os.environ, "PATH": str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"),
"PYTHONHOME": "C:/NONEXISTENT", "PYTHONPATH": "C:/NONEXISTENT", "PYTHONNOUSERSITE": "1",
"HTTP_PROXY": "http://127.0.0.1:9", "HTTPS_PROXY": "http://127.0.0.1:9", "ALL_PROXY": "http://127.0.0.1:9"}
def run(*args):
result = subprocess.run([str(item) for item in args], env=environment, capture_output=True,
encoding="utf-8", errors="replace", timeout=180)
if result.returncode:
raise RuntimeError(json.dumps({"code": result.returncode, "stdout": result.stdout[-500:], "stderr": result.stderr[-2500:]}))
return result
def picker(manager, folder):
import base64
encoded = base64.b64encode(json.dumps(str(folder)).encode()).decode()
process = subprocess.Popen([str(manager), "--pick-folder", "--initial-folder", encoded], env=environment,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW)
user32 = ctypes.windll.user32
callback_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
dialogs = []
def callback(window, unused):
pid = ctypes.c_ulong(); user32.GetWindowThreadProcessId(window, ctypes.byref(pid))
if pid.value == process.pid:
name = ctypes.create_unicode_buffer(256); user32.GetClassNameW(window, name, 256)
if name.value == "#32770" and user32.IsWindowVisible(window): dialogs.append(window)
return True
deadline = time.monotonic() + 15
handler = callback_type(callback)
while not dialogs and time.monotonic() < deadline:
user32.EnumWindows(handler, 0); time.sleep(0.1)
assert dialogs, "The bundled Windows folder picker must open"
# Select its preselected existing folder, including Unicode in the path.
user32.PostMessageW(ctypes.c_void_p(dialogs[0]), 0x0111, 1, 0) # WM_COMMAND / IDOK
output, errors = process.communicate(timeout=15)
assert process.returncode == 0, errors.decode("utf-8", errors="replace")
assert Path(json.loads(output)) == folder
with tempfile.TemporaryDirectory(prefix="offline-bundle-", dir=ARTIFACTS) as temporary:
parent = Path(temporary)
application = parent / "Helper"
legacy = application / ".runtime"; legacy.mkdir(parents=True)
(legacy / "completed-jobs.json").write_text("[]")
first = run(INSTALLER, "--silent", "--no-register", "--target-root", application)
assert (application / "data/completed-jobs.json").read_text() == "[]"
marker = json.loads((application / "installation.json").read_text(encoding="utf-8-sig"))
release = Path(marker["current"])
python = release / "runtime/python/python.exe"
measured = json.loads(run(python, "-I", "-B", "-c",
"import json,sys; from host.media import capabilities,tool; print(json.dumps({'python':sys.executable,'path':sys.path,'hardware':capabilities(),'tools':{k:tool(k) for k in ('node','ffmpeg','ffprobe')}}))").stdout)
assert measured["hardware"]["ready"]
assert measured["hardware"]["bundle"]["mode"] == "autonomous"
assert {"h264", "hevc", "av1"} <= set(measured["hardware"]["nvenc"]), measured["hardware"]
assert Path(measured["python"]).is_relative_to(release)
assert all(Path(path).is_relative_to(release) for path in measured["tools"].values())
assert all(Path(path).is_relative_to(release) for path in measured["path"])
picker(application / "YouTubeDL-Manager.exe", ARTIFACTS)
preserved = release / "user-kept.mp4"; preserved.write_bytes(b"user file")
history = application / "data/completed-jobs.json"; history.parent.mkdir(exist_ok=True); history.write_text("[]")
# Reinstall beside the running copy; an old DLL handle must not prevent repair.
handle = (release / "runtime/python/python313.dll").open("rb")
try: run(INSTALLER, "--silent", "--no-register", "--target-root", application)
finally: handle.close()
current = Path(json.loads((application / "installation.json").read_text(encoding="utf-8-sig"))["current"])
assert current != release and current.is_dir()
manager = application / "YouTubeDL-Manager.exe"
run(manager, "--uninstall", "--silent", "--target-root", application)
assert preserved.read_bytes() == b"user file" and history.read_text() == "[]"
assert not current.exists() and not python.exists()
deadline = time.monotonic() + 15
while manager.exists() and time.monotonic() < deadline: time.sleep(0.1)
assert not manager.exists(), "The uninstaller must remove its own installed executable"
# Payload damage must be rejected before registering or installing any tools.
damaged = parent / "damaged.exe"
payload = bytearray(INSTALLER.read_bytes()); offset = payload.index(b"PK\x03\x04"); payload[offset + 100] ^= 1
damaged.write_bytes(payload)
invalid = subprocess.run([str(damaged), "--silent", "--no-register", "--target-root", str(parent / "Invalid")],
env=environment, capture_output=True, encoding="utf-8", timeout=60)
assert invalid.returncode != 0 and "Контрольная сумма" in invalid.stderr, invalid.stderr
(ARTIFACTS / "offline-bundle.json").write_text(json.dumps({"offlineInstallation": "passed", "isolatedRuntime": "passed",
"folderPicker": "passed", "repairWhileOldFilesOpen": "passed", "uninstallPreservesUserMedia": "passed", "corruptedPayload": "rejected",
"versions": measured["hardware"]["dependencies"], "nvenc": measured["hardware"]["nvenc"]}, ensure_ascii=False, indent=2), encoding="utf-8")
print("Offline bundle passed: isolated runtime, blocked proxies, Windows picker, NVENC, repair, scoped uninstall and damaged-payload rejection.")
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport: { width: 520, height: 820 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await context.route("https://ui.ytdl.test/**", async route => {
const name = new URL(route.request().url()).pathname.slice(1);
if (!["downloader.html", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "styles.css", "shared.js", "session.js"].includes(name)) return route.abort();
return route.fulfill({ body: await fs.readFile(path.join(root, "extension", name)), contentType: name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript" });
});
const errors = [];
context.on("page", page => page.on("pageerror", error => errors.push(error.message)));
try {
const page = await context.newPage();
await page.addInitScript(() => { window.__firstRun = true; window.__helperMissing = true; });
await page.goto("https://ui.ytdl.test/downloader.html?theme=dark");
await page.locator("#setup[open]").waitFor();
await page.screenshot({ path: path.join(artifacts, "onboarding-welcome.png"), animations: "disabled" });
await page.locator("#setup-next").click();
await page.locator('#setup-helper[data-state="missing"]').waitFor();
assert.equal(await page.locator("#setup-next").isDisabled(), true);
assert.equal(await page.locator("#setup-install").isVisible(), true);
assert.equal(await page.locator("#setup-install").getAttribute("href"), "https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest");
assert.equal(await page.locator("#setup-install").getAttribute("target"), "_blank");
assert.equal(await page.locator("#setup-install").getAttribute("download"), null);
assert.equal(await page.locator("#setup-install-steps li").count(), 3);
await page.screenshot({ path: path.join(artifacts, "onboarding-install.png"), animations: "disabled" });
// A connected helper with a broken component is insufficient to continue.
await page.evaluate(() => { window.__helperMissing = false; window.__dependencyMissing = true; });
await page.locator("#setup-check").click();
await page.locator("#setup-check:not([disabled])").waitFor();
assert.equal(await page.locator("#setup-next").isDisabled(), true);
assert.match(await page.locator("#setup-install").textContent(), /восстановления/);
await page.evaluate(() => { window.__dependencyMissing = false; });
await page.locator("#setup-check").click();
await page.locator("#setup-next:not([disabled])").waitFor();
assert.match(await page.locator("#setup-helper-message").textContent(), /5\/5/);
assert.match(await page.locator("#setup-gpu").textContent(), /RTX 5080.*NVENC/);
await page.locator("#setup-updates").uncheck();
await page.locator("#setup-next:not([disabled])").waitFor();
assert.equal(await page.locator("#setup-updates").isChecked(), false);
await page.locator("#setup-next").click();
await page.locator("#setup-folder").fill("");
await page.locator("#setup-next").click();
assert.match(await page.locator("#setup-error").textContent(), /Выберите папку/);
await page.locator("#setup-pick-folder").click();
await page.locator("#setup-pick-folder:not([disabled])").waitFor();
assert.equal(await page.locator("#setup-folder").inputValue(), "D:\\Videos\\YouTube");
await page.locator("#setup-pick-work-folder").click();
await page.locator("#setup-pick-work-folder:not([disabled])").waitFor();
assert.equal(await page.locator("#setup-work-folder").inputValue(), "D:\\Videos\\YouTube");
await page.locator("#setup-work-folder").fill("F:\\Scratch");
await page.locator("#setup-mode").selectOption("audio");
assert.equal(await page.locator("#setup-quality-field").isVisible(), false);
await page.locator("#setup-format").selectOption("mp3");
await page.locator("#setup-mode").selectOption("video");
await page.locator("#setup-format").selectOption("mkv");
await page.locator("#setup-quality").selectOption("1080");
await page.screenshot({ path: path.join(artifacts, "onboarding-settings.png"), animations: "disabled" });
await page.locator("#setup-next").click();
await page.locator('[data-setup-step="3"]').waitFor({ state: "visible" });
assert.match(await page.locator("#setup-saved").textContent(), /D:\\Videos\\YouTube/);
await page.locator("#setup-next").click();
await page.locator("#setup").waitFor({ state: "hidden" });
const result = await page.evaluate(() => window.__fixtureStorage);
assert.equal(result.onboarding.state, "complete");
assert.equal(result.preferences.videoContainer, "mkv");
assert.equal(result.preferences.audioContainer, "mp3");
assert.equal(result.preferences.quality.height, 1080);
assert.equal(result.preferences.workFolder, "F:\\Scratch");
assert.equal(result.preferences.engine, "auto");
await page.locator("#open-setup").click();
await page.locator("#setup[open]").waitFor();
await page.keyboard.press("Escape");
await page.locator("#setup").waitFor({ state: "hidden" });
assert.equal(await page.evaluate(() => window.__fixtureStorage.onboarding.state), "skipped");
await page.close();
const narrow = await context.newPage();
await narrow.setViewportSize({ width: 340, height: 560 });
await narrow.addInitScript(() => { window.__firstRun = true; });
await narrow.goto("https://ui.ytdl.test/downloader.html?theme=light");
await narrow.locator("#setup[open]").waitFor();
await narrow.locator("#setup-next").click();
await narrow.locator("#setup-next:not([disabled])").waitFor();
const box = await narrow.locator("#setup").boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= 340 && box.y >= 0 && box.y + box.height <= 560);
assert.equal(await narrow.locator("#setup-next").isVisible(), true);
await narrow.screenshot({ path: path.join(artifacts, "onboarding-light-narrow.png"), animations: "disabled" });
await narrow.locator("#setup-skip").click();
await narrow.locator("#setup").waitFor({ state: "hidden" });
assert.equal(await narrow.evaluate(() => window.__fixtureStorage.onboarding.state), "skipped");
await narrow.close();
const existing = await context.newPage();
await existing.addInitScript(() => { window.__firstRun = true; window.__fixtureStorage.preferences = { folder: "D:\\Existing", videoContainer: "webm" }; });
await existing.goto("https://ui.ytdl.test/downloader.html");
await existing.locator('#helper-panel[data-state="running"]').waitFor();
assert.equal(await existing.locator("#setup").evaluate(dialog => dialog.open), false, "Configured profiles are not interrupted by a new guide");
assert.equal(await existing.locator("#folder").inputValue(), "D:\\Existing");
assert.deepEqual(errors, []);
console.log("Onboarding passed: first launch, missing/broken helper, real-state gating, update toggle, folder validation, video/audio defaults, skip, reopen, Escape, existing profiles and narrow light theme.");
} finally { await browser.close(); }
+176
View File
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
// Real extension toast on live YouTube, with controlled progress messages.
// Settings use their real active-job renderer and the existing browser-API fixture.
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "ytdl-motion-test-"));
const context = await chromium.launchPersistentContext(profile, {
headless: true, viewport: { width: 1280, height: 900 }, reducedMotion: "no-preference",
executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging", "--mute-audio"],
});
try {
const cdp = await context.browser().newBrowserCDPSession();
await cdp.send("Extensions.loadUnpacked", { path: path.join(root, "extension") });
const worker = context.serviceWorkers()[0] || await context.waitForEvent("serviceworker");
const page = await context.newPage();
await page.goto("https://www.youtube.com/results?search_query=subnautica+shorts", { waitUntil: "domcontentloaded", timeout: 60000 });
const consent = page.getByRole("button", { name: /Reject all|Отклонить все/i }).first();
if (await consent.isVisible()) await consent.click();
await page.waitForTimeout(700);
const tabId = await worker.evaluate(async url => (await chrome.tabs.query({})).find(tab => tab.url === url).id, page.url());
await context.route("https://ui.ytdl.test/**", async route => {
const name = new URL(route.request().url()).pathname.slice(1);
if (!["downloader.html", "styles.css", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "shared.js", "session.js"].includes(name)) return route.abort();
await route.fulfill({ body: await fs.readFile(path.join(root, "extension", name)), contentType: name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript" });
});
const menu = await context.newPage();
await menu.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
const errors = []; menu.on("pageerror", error => errors.push(error.message));
await menu.goto("https://ui.ytdl.test/downloader.html?theme=dark");
await menu.locator('#helper-panel[data-state="running"]').waitFor();
const jobId = "motion-preview";
const emit = async (values = {}) => {
const job = { id: jobId, title: "Проверка анимации прогресса", status: "running", stage: "downloading", track: "video", percent: 25, downloaded: 123.4 * 1048576, total: 850 * 1048576, speed: 41.5 * 1048576, ...values };
await worker.evaluate(async ({ tabId, job }) => { await chrome.tabs.sendMessage(tabId, { type: "JOB_PROGRESS", job }).catch(() => {}); }, { tabId, job });
await menu.evaluate(job => window.__emitJob(job), job);
await page.locator(`#ytdl-notifications [data-id="${jobId}"]`).waitFor();
};
const toastFill = page.locator(`#ytdl-notifications [data-id="${jobId}"] .fill`);
const historyFill = menu.locator(`[data-job-id="${jobId}"] .progress-fill`);
const toastTransfer = page.locator(`#ytdl-notifications [data-id="${jobId}"] .transfer`);
const historyTransfer = menu.locator(`[data-job-id="${jobId}"] .job-transfer`);
async function checkSize(values, expected, expectedHistory = expected) {
await emit(values);
await page.waitForFunction(({ id, text }) => document.querySelector('#ytdl-notifications').shadowRoot.querySelector(`[data-id="${id}"] .transfer`).textContent === text, { id: jobId, text: expected });
for (const [label, text] of [[toastTransfer, expected], ...(values.status && values.status !== "running" ? [] : [[historyTransfer, expectedHistory]])]) {
assert.equal(await label.textContent(), text);
assert.equal(await label.evaluate(node => node.hidden), !text);
if (text) assert.equal(await label.evaluate(node => node.scrollWidth <= node.clientWidth), true, "Byte count must fit without truncation");
}
}
async function sample(fill, indeterminate) {
await fill.page().bringToFront();
return fill.evaluate(async (node, waiting) => {
const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await frame();
void getComputedStyle(node, '::after').animationName;
const scope = node.getRootNode();
// Chrome exposes pseudo-element animations through the ShadowRoot timeline.
const animation = scope.getAnimations().find(item => item.effect?.target === node && item instanceof CSSAnimation);
if (!animation) throw new Error("Running progress must actually animate: " + JSON.stringify({
node: node.outerHTML, reduced: matchMedia('(prefers-reduced-motion: reduce)').matches,
pseudo: { animation: getComputedStyle(node, '::after').animationName, duration:getComputedStyle(node, '::after').animationDuration, content: getComputedStyle(node, '::after').content },
animations: node.getAnimations({subtree:true}).map(item => ({name:item.animationName,type:item.constructor.name,css:item instanceof CSSAnimation})),
frames: [...(node.getRootNode().querySelector('style')?.sheet?.cssRules || [])].filter(rule=>rule.type===CSSRule.KEYFRAMES_RULE).map(rule=>rule.name),
rootAnimations: node.getRootNode().getAnimations?.().map(item=>({name:item.animationName,target:item.effect?.target?.className})),
geometry:node.getBoundingClientRect().toJSON(),visibility:document.visibilityState,
}));
animation.pause();
const duration = animation.effect.getTiming().duration;
const track = node.parentElement, positions = [];
for (const phase of [0, .125, .25, .375, .5, .625, .75, .875, .999]) {
animation.currentTime = duration * phase; await frame();
const bar = node.getBoundingClientRect(), bounds = track.getBoundingClientRect();
if (waiting && (bar.left < bounds.left - .2 || bar.right > bounds.right + .2)) throw new Error("Waiting segment escaped the track");
// A pseudo-element painted outside its fill also intercepts this hit test.
// Probe the unfilled track, rather than merely checking CSS property names.
for (let x = bounds.left + 2; x < bounds.right - 2; x += 2) {
if (x >= bar.left - 1 && x <= bar.right + 1) continue;
const hit = scope.elementFromPoint(x, bounds.top + bounds.height / 2);
if (hit === node) throw new Error("Moving highlight leaked into the unfilled track");
}
positions.push({ phase, left: bar.left - bounds.left, right: bar.right - bounds.left, trackWidth: bounds.width });
}
if (waiting) {
const travel = Math.max(...positions.map(item => item.left)) - Math.min(...positions.map(item => item.left));
if (travel < positions[0].trackWidth * .5) throw new Error("Waiting animation barely moves");
for (let i = 1; i < 5; i++) if (positions[i].left < positions[i - 1].left - .2) throw new Error("Waiting motion changes direction too early");
for (let i = 5; i < positions.length; i++) if (positions[i].left > positions[i - 1].left + .2) throw new Error("Waiting motion does not return smoothly");
}
animation.currentTime = duration * (waiting ? .5 : .75); await frame();
return positions;
}, indeterminate);
}
const results = [];
await emit();
await page.waitForTimeout(400);
await page.evaluate(id => {
const toast = document.querySelector('#ytdl-notifications').shadowRoot.querySelector(`[data-id="${id}"]`);
window.__unchangedReadoutMutations = [];
window.__readoutObserver = new MutationObserver(records => window.__unchangedReadoutMutations.push(...records.map(record => record.target.parentElement?.className || record.target.className)));
for (const selector of ['.title', '.stage', '.icon', '.label', '.action']) window.__readoutObserver.observe(toast.querySelector(selector), { childList: true, characterData: true, subtree: true });
}, jobId);
await menu.evaluate(() => {
window.__cardMoves = 0;
window.__cardObserver = new MutationObserver(records => { window.__cardMoves += records.length; });
window.__cardObserver.observe(document.querySelector('#active-jobs'), { childList: true });
});
for (let index = 0; index < 10; index++) await emit({ downloaded: (124 + index) * 1048576 });
const unchangedReadoutMutations = await page.evaluate(() => {
window.__readoutObserver.disconnect();
return window.__unchangedReadoutMutations;
});
assert.deepEqual(unchangedReadoutMutations, [], 'Unchanged progress labels must not be rebuilt for each byte-count event');
assert.equal(await menu.evaluate(() => { window.__cardObserver.disconnect(); return window.__cardMoves; }), 0, 'A progress update must not detach and reinsert the active card');
const readout = page.locator(`#ytdl-notifications [data-id="${jobId}"] .label`);
await readout.evaluate(node => { window.__progressLabelTextNode = node.firstChild; });
await emit({ percent: 26, speed: 4.2 * 1048576 });
assert.equal(await readout.textContent(), '26% · 4.2 МБ/с');
assert.equal(await readout.evaluate(node => node.firstChild === window.__progressLabelTextNode), true, 'Changing measurements must update the existing text node');
await emit({ speed: 0 });
assert.equal(await readout.textContent(), '25% · 0.0 МБ/с', 'An actual zero-speed measurement must remain visible');
for (const width of [1280, 360]) {
await page.setViewportSize({ width, height: 900 }); await menu.setViewportSize({ width: Math.min(width, 520), height: 900 });
await checkSize({ downloaded: 0 }, "Скачано 0,0 МБ из 850,0 МБ");
await checkSize({}, "Скачано 123,4 МБ из 850,0 МБ");
await checkSize({ track: "audio", downloaded: 12.8 * 1048576, total: 24.4 * 1048576 }, "Скачано 12,8 МБ из 24,4 МБ");
await checkSize({ downloaded: 12.8 * 1048576, total: 0 }, "Скачано 12,8 МБ");
await checkSize({ downloaded: null, total: null }, "");
await checkSize({ stage: "encoding", engine: "nvenc" }, "");
assert.equal(await page.locator(`#ytdl-notifications [data-id="${jobId}"] .stage`).textContent(), "Перекодирование · NVIDIA NVENC");
await checkSize({ stage: "remuxing", engine: "copy", percent: 35, processedSeconds: 84, durationSeconds: 240, written: 1234.5 * 1048576, speed: "120x", eta: 1 }, "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ", "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ · осталось ≈ 1 с");
assert.equal(await page.locator(`#ytdl-notifications [data-id="${jobId}"] .stage`).textContent(), "Подготовка файла · без перекодирования");
await checkSize({ stage: "audio", engine: "cpu", percent: 35, processedSeconds: 84, durationSeconds: 240, written: 1234.5 * 1048576, speed: "48x" }, "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ");
assert.equal(await page.locator(`#ytdl-notifications [data-id="${jobId}"] .stage`).textContent(), "Обработка аудио · CPU");
await checkSize({ stage: "finalizing", engine: "copy", percent: 99, processedSeconds: 240, durationSeconds: 240, written: 1234.5 * 1048576, speed: "", eta: null, message: "Финализация файла" }, "Обработано 4:00 из 4:00 · Записано 1 234,5 МБ");
assert.equal(await page.locator(`#ytdl-notifications [data-id="${jobId}"] .stage`).textContent(), "Финализация файла");
await checkSize({ stage: "merging", percent: 35, processedSeconds: 84, durationSeconds: 240, written: 1234.5 * 1048576, speed: "38.6x", eta: 4.1 }, "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ", "Обработано 1:24 из 4:00 · Записано 1 234,5 МБ · осталось ≈ 5 с");
await page.locator(`#ytdl-notifications [data-id="${jobId}"]`).screenshot({ path: path.join(artifacts, `progress-merging-${width}.png`) });
await checkSize({ stage: "saving", copied: 256 * 1048576, total: 1024 * 1048576, percent: 25, speed: null }, "Сохранено 256,0 МБ из 1 024,0 МБ");
await emit(); await page.waitForTimeout(400);
results.push({ width, state: "25%", toast: await sample(toastFill, false), history: await sample(historyFill, false) });
await emit({ percent: 90 }); await page.waitForTimeout(350);
// Enter waiting from a wide filled bar to catch an unwanted width transition.
await emit({ percent: null, stage: "preparing" });
results.push({ width, state: "waiting", toast: await sample(toastFill, true), history: await sample(historyFill, true) });
await page.locator(`#ytdl-notifications [data-id="${jobId}"]`).screenshot({ path: path.join(artifacts, `progress-waiting-${width}.png`) });
await emit({ percent: 25 }); await page.waitForTimeout(350);
results.push({ width, state: "resumed", toast: await sample(toastFill, false), history: await sample(historyFill, false) });
await page.locator(`#ytdl-notifications [data-id="${jobId}"]`).screenshot({ path: path.join(artifacts, `progress-downloading-${width}.png`) });
}
for (const status of ["complete", "error", "cancelled"]) {
await checkSize({ status, stage: status, percent: status === "complete" ? 100 : 25, result: status === "complete" ? { filename: "Проверка.mp4", size: 1024.5 * 1048576 } : undefined }, status === "complete" ? "Размер файла: 1 024,5 МБ" : "");
await page.waitForTimeout(350);
assert.equal(await historyFill.count(), 0, `${status}: terminal jobs move out of settings`);
assert.equal(await toastFill.evaluate(node => node.getRootNode().getAnimations().filter(item => item.effect?.target === node && item instanceof CSSAnimation).length), 0, `${status}: moving effects must stop`);
}
await page.emulateMedia({ reducedMotion: "reduce" }); await menu.emulateMedia({ reducedMotion: "reduce" });
for (const percent of [25, null]) {
await emit({ percent }); await page.waitForTimeout(30);
for (const fill of [toastFill, historyFill]) assert.equal(await fill.evaluate(node => node.getRootNode().getAnimations().filter(item => item.effect?.target === node).length), 0, "Reduced motion must disable moving effects");
}
assert.deepEqual(errors, []);
await fs.writeFile(path.join(artifacts, "progress-motion-result.json"), JSON.stringify({ version: JSON.parse(await fs.readFile(path.join(root, "extension/manifest.json"))).version, actualYouTubeToast: true, stages: results, terminalEffectsStopped: true, reducedMotion: true }, null, 2));
console.log("Progress motion passed: actual Edge/YouTube toast, stable readouts and history cards, zero-speed display, highlight clipping, bounded waiting motion, resume, narrow layout, terminal states and reduced motion.");
} finally {
await context.close();
if (path.dirname(path.resolve(profile)) === path.resolve(os.tmpdir()) && path.basename(profile).startsWith("ytdl-motion-test-")) await fs.rm(profile, { recursive: true, force: true });
}
+93
View File
@@ -0,0 +1,93 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readSession, SESSION_PERMISSION } from "../extension/session.js";
const sampleCookie = value => ({ name: "SAPISID", value, domain: ".youtube.com", path: "/", secure: true, httpOnly: true, hostOnly: false });
test("session access needs both opt-in and permission and does not return secrets to the UI", async () => {
let enabled = false, permission = false, reads = 0;
globalThis.chrome = {
storage: { local: { get: async () => ({ youtubeSession: enabled }) } },
permissions: { contains: async () => permission },
cookies: {
getAllCookieStores: async () => [{ id: "current-profile", tabIds: [42] }],
getAll: async options => {
reads++; assert.equal(options.domain, "youtube.com"); assert.equal(options.storeId, "current-profile");
return [sampleCookie("synthetic-session"), { ...sampleCookie("other"), domain: ".google.com" },
{ ...sampleCookie("evil"), domain: "youtube.com.evil.test" }, { ...sampleCookie("expired"), expirationDate: 1 },
{ ...sampleCookie("partitioned"), partitionKey: { topLevelSite: "https://another.test" } }];
},
},
};
assert.equal((await readSession(42)).status, "disabled"); assert.equal(reads, 0);
enabled = true;
assert.equal((await readSession(42)).status, "permission_required"); assert.equal(reads, 0);
permission = true;
const status = await readSession(42);
assert.equal(status.status, "connected"); assert.ok(!JSON.stringify(status).includes("synthetic-session"));
const authenticated = await readSession(42, true);
assert.equal(authenticated.cookies.length, 1); assert.equal(authenticated.cookies[0].value, "synthetic-session");
await assert.rejects(readSession(999, true), /профиль вкладки/);
});
test("background passes fresh YouTube cookies to both worker actions and never persists them", async () => {
const storage = {}, requests = [], listeners = {}, pendingUi = new Map();
let permission = false, cookieValue = "synthetic-original", cookieReads = 0, nativeListener;
const noEvent = { addListener() {} };
const native = {
onMessage: { addListener(listener) { nativeListener = listener; } }, onDisconnect: noEvent, disconnect() {},
postMessage(message) {
requests.push(message);
const result = message.action === "hello" ? { version: "1.3.2", ready: true, supportsBrowserCookies: true } : message.action === "ping" ? { alive: true }
: message.action === "download" ? { jobId: "auth-test-job" } : { title: "Synthetic video", videos: [], audios: [] };
queueMicrotask(() => nativeListener({ id: message.id, ok: true, result }));
},
};
globalThis.chrome = {
runtime: { id: "test-id", getManifest: () => ({ version: "1.4.5" }), getURL: name => `chrome-extension://test-id/${name}`, connectNative: () => native,
onConnect: { addListener(listener) { listeners.connect = listener; } }, onMessage: noEvent },
permissions: { contains: async () => permission, remove: async input => { assert.deepEqual(input, SESSION_PERMISSION); permission = false; return true; } },
cookies: { getAllCookieStores: async () => [{ id: "0", tabIds: [42] }], getAll: async () => { cookieReads++; return [sampleCookie(cookieValue)]; } },
storage: { local: { get: async key => Object.fromEntries((Array.isArray(key) ? key : [key]).map(name => [name, storage[name]])),
set: async values => Object.assign(storage, structuredClone(values)) } },
notifications: { clear: async () => {}, create: async () => {}, onButtonClicked: noEvent },
tabs: { sendMessage: async () => {} }, action: { onClicked: noEvent, setBadgeText() {}, setBadgeBackgroundColor() {} },
};
await import("../extension/background.js?session-test");
let uiListener;
listeners.connect({ name: "ytdl-ui", sender: { id: "test-id", url: "chrome-extension://test-id/downloader.html" },
onDisconnect: noEvent, onMessage: { addListener(listener) { uiListener = listener; } },
postMessage(message) { if (message.id) { const done = pendingUi.get(message.id); pendingUi.delete(message.id); done(message); } } });
const call = (action, payload = {}) => new Promise(resolve => {
const id = crypto.randomUUID(); pendingUi.set(id, resolve); uiListener({ id, action, payload });
});
assert.equal((await call("hello")).ok, true);
assert.equal((await call("session_enable", { enabled: true, tabId: 42 })).ok, false);
const url = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
await call("inspect", { url, tabId: 42, browserCookies: [sampleCookie("caller-injected")] });
assert.equal(cookieReads, 0); assert.ok(!requests.at(-1).payload.browserCookies);
permission = true;
const enabled = await call("session_enable", { enabled: true, tabId: 42 });
assert.equal(enabled.result.status, "connected"); assert.ok(!JSON.stringify(enabled).includes(cookieValue));
await call("inspect", { url, tabId: 42 });
assert.equal(requests.at(-1).payload.browserCookies[0].value, cookieValue);
const inspectionRequests = requests.filter(request => request.action === "inspect").length;
await call("inspect", { url, tabId: 42 });
assert.equal(requests.filter(request => request.action === "inspect").length, inspectionRequests, "Reuse a recently inspected video's quality without another YouTube request");
await call("inspect", { url, tabId: 42, refresh: true });
assert.equal(requests.filter(request => request.action === "inspect").length, inspectionRequests + 1, "Explicit refresh bypasses the summary cache");
cookieValue = "synthetic-rotated";
await call("download", { url, tabId: 42, title: "Test", folder: "D:/Videos" });
assert.equal(requests.at(-1).payload.browserCookies[0].value, cookieValue);
nativeListener({ event: "job", job: { id: "auth-test-job", status: "complete", stage: "complete", result: { title: "Test", filename: "test.mp4", path: "D:/Videos/test.mp4" } } });
await new Promise(resolve => setTimeout(resolve, 20));
assert.ok(!JSON.stringify(storage).includes("synthetic-")); assert.ok(!JSON.stringify(storage).includes("browserCookies"));
await call("session_enable", { enabled: false, tabId: 42 });
const readsBefore = cookieReads;
await call("inspect", { url, tabId: 42 });
assert.equal(cookieReads, readsBefore); assert.ok(!requests.at(-1).payload.browserCookies);
// An expired/removed permission must still allow ordinary public downloads.
storage.youtubeSession = true;
assert.equal((await call("inspect", { url, tabId: 42, refresh: true })).ok, true);
assert.ok(!requests.at(-1).payload.browserCookies);
});
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:http";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const version = JSON.parse(await fs.readFile(path.join(root,"extension/manifest.json"),"utf8")).version;
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url, "http://localhost");
if (!url.pathname.startsWith("/YouTubeDL/")) { response.writeHead(404).end(); return; }
const relative = decodeURIComponent(url.pathname.slice("/YouTubeDL/".length)) || "index.html";
const file = path.resolve(root,"site",relative);
if (!file.startsWith(path.resolve(root,"site") + path.sep)) { response.writeHead(400).end(); return; }
const type = {".html":"text/html; charset=utf-8",".js":"text/javascript",".css":"text/css",".png":"image/png",".svg":"image/svg+xml"}[path.extname(file)] || "application/octet-stream";
response.writeHead(200,{"Content-Type":type});response.end(await fs.readFile(file));
} catch { response.writeHead(404).end(); }
});
await new Promise(resolve => server.listen(0,"127.0.0.1",resolve));
const local = `http://127.0.0.1:${server.address().port}/YouTubeDL/`;
const target = process.env.YTDL_SITE_URL || local;
let browser;
try {
browser = await chromium.launch({ headless:true, executablePath:process.env.YTDL_TEST_BROWSER || "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport:{width:1440,height:1050}, reducedMotion:"reduce", colorScheme:"light" });
const page = await context.newPage(), failures=[];
page.on("pageerror",error => failures.push(error.message));
page.on("response",response => { if (response.url().startsWith(target) && response.status() >= 400) failures.push(`${response.status()} ${response.url()}`); });
await page.goto(target,{waitUntil:"networkidle"});
assert.equal(await page.locator("h1").count(),1);
await page.locator(".hero .primary").click();
assert.equal(new URL(page.url()).hash,"#install");
assert.ok(await page.locator(".download-grid").isVisible());
assert.equal(await page.locator('[data-download="extension"]').getAttribute("href"),`https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/download/v${version}/YouTubeDL-${version}.zip`);
assert.equal(await page.locator('[data-download="helper"]').getAttribute("href"),"https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/download/v1.4.5/YouTubeDL-Helper-Setup.exe");
await page.locator('[data-screenshot="light"]').click();
await page.locator("#menu-screenshot").evaluate(image => image.decode());
assert.match(await page.locator("#menu-screenshot").getAttribute("src"), /menu-light\.png$/);
await page.locator('[data-screenshot="dark"]').click();
await page.locator("#menu-screenshot").evaluate(image => image.decode());
await page.locator("#theme-toggle").click();
assert.equal(await page.locator("html").getAttribute("data-theme"),"dark");
await page.reload({waitUntil:"networkidle"});
assert.equal(await page.locator("html").getAttribute("data-theme"),"dark");
await page.locator("#theme-toggle").click();
await page.evaluate(() => scrollTo(0,0));
await page.locator(".hero").screenshot({path:path.join(artifacts,"site-hero.png")});
await page.locator(".download-grid").screenshot({path:path.join(artifacts,"site-downloads.png")});
await page.screenshot({path:path.join(artifacts,"site-desktop.png"),fullPage:true});
for (const width of [320,390,768]) {
await page.setViewportSize({width,height:844});
await page.evaluate(() => scrollTo(0,0));
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")].filter(node => node.getBoundingClientRect().right > innerWidth + 1 && getComputedStyle(node).position !== "absolute").map(node => ({tag:node.tagName,class:node.className,right:Math.round(node.getBoundingClientRect().right)})));
if (await page.evaluate(() => document.documentElement.scrollWidth > innerWidth)) await page.screenshot({path:path.join(artifacts,`site-overflow-${width}.png`),fullPage:true});
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),`No horizontal overflow at ${width}px: ${JSON.stringify(overflow)}`);
assert.ok(await page.locator("h1").isVisible());
await page.locator('nav a[href="#install"]').click();
assert.equal(new URL(page.url()).hash,"#install");
const summary = page.locator(".faqs summary").first();await summary.click();
assert.equal(await page.locator(".faqs details").first().getAttribute("open"),"");
await summary.click();
await page.evaluate(() => scrollTo(0,0));
if(width===390) await page.screenshot({path:path.join(artifacts,"site-mobile.png"),fullPage:true});
}
const images=await page.locator("img").evaluateAll(nodes => nodes.map(image=>({src:image.getAttribute("src"),okay:image.complete&&image.naturalWidth>0})));
assert.ok(images.every(image=>image.okay),JSON.stringify(images));
assert.deepEqual(failures,[]);
await fs.writeFile(path.join(artifacts,"site-check.json"),JSON.stringify({url:target,desktop:"passed",widths:[320,390,768],themes:"passed",screenshots:"passed",downloadLinks:"passed",faq:"passed",errors:failures},null,2));
console.log(`Site passed at ${target}: desktop/mobile, Pages subpath, download links, theme persistence, screenshots, FAQ and no console/HTTP errors.`);
} finally { if(browser)await browser.close();await new Promise(resolve=>server.close(resolve)); }
+22
View File
@@ -0,0 +1,22 @@
param(
[Parameter(Mandatory=$true)][string]$Name,
[Parameter(Mandatory=$true)][string]$ManifestPath,
[switch]$Remove
)
$ErrorActionPreference = 'Stop'
if ($Name -notmatch '^com\.youtubedl\.test_[a-f0-9]{16}$') { throw 'Only isolated test hosts are allowed.' }
$taskManifest = [IO.Path]::GetFullPath($ManifestPath)
$taskKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Edge\NativeMessagingHosts\$Name"
if (Test-Path -LiteralPath $taskKey) {
$taskRegistered = (Get-Item -LiteralPath $taskKey).GetValue('')
if ($taskRegistered -and $taskRegistered -ne $taskManifest) { throw 'Test registration belongs to another path.' }
}
if ($Remove) {
if (Test-Path -LiteralPath $taskKey) { Remove-Item -LiteralPath $taskKey }
} else {
$taskData = Get-Content -LiteralPath $taskManifest -Raw | ConvertFrom-Json
if ($taskData.name -ne $Name) { throw 'Wrong test host manifest.' }
New-Item -Path $taskKey -Force | Out-Null
Set-Item -LiteralPath $taskKey -Value $taskManifest
if ((Get-Item -LiteralPath $taskKey).GetValue('') -ne $taskManifest) { throw 'Test registration was not written.' }
}
+228
View File
@@ -0,0 +1,228 @@
import io
import json
import os
import struct
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from host.common import safe_filename, select_streams, validate_download, youtube_url
from host.media import encoding_plan, ffmpeg_command
from host.protocol import MAX_MESSAGE, read_message, write_message
from host.worker import publish_file
from host.main import NativeHost
from host.session import apply_cookies, validate_cookies, public_error
class HostTests(unittest.TestCase):
def test_fragment_access_failure_retries_and_preserves_the_original_error(self):
from unittest.mock import MagicMock
from yt_dlp.utils import DownloadError
from host.worker import download
first, second = MagicMock(), MagicMock()
first.__enter__.return_value = first; second.__enter__.return_value = second
handles = []
def fail(ydl):
# Reproduce a downloader retaining its .part handle during failure.
part = Path(ydl.params["outtmpl"].replace("%(ext)s", "mp4") + ".part")
handles.append(part.open("wb"))
raise DownloadError("HTTP Error 403: Forbidden")
def create_ydl(params):
current = first if not handles else second
current.params = params
current.extract_info.side_effect = lambda *args, **kwargs: fail(current)
return current
with tempfile.TemporaryDirectory() as folder:
try:
with patch("yt_dlp.YoutubeDL", side_effect=create_ydl), patch("host.worker.ydl_options", return_value={}), \
patch("host.worker.time.sleep"), patch("host.worker.emit") as emit:
with self.assertRaisesRegex(DownloadError, "HTTP Error 403"):
download({"url": "https://youtu.be/jNQXAC9IVRw", "folder": folder}, [])
self.assertEqual(len(handles), 2)
self.assertTrue(any("повторяю загрузку" in str(call) for call in emit.call_args_list))
finally:
for handle in handles: handle.close()
def test_transient_youtube_challenge_is_retried_once_without_browser_cookies(self):
from unittest.mock import MagicMock
from yt_dlp.utils import DownloadError
first, second = MagicMock(), MagicMock()
first.__enter__.return_value = first; second.__enter__.return_value = second
first.extract_info.side_effect = DownloadError("Sign in to confirm you’re not a bot")
second.extract_info.return_value = {"id": "jNQXAC9IVRw"}
with patch("yt_dlp.YoutubeDL", side_effect=[first, second]), patch("host.worker.ydl_options", return_value={}), \
patch("host.worker.summarize", side_effect=lambda info: info), patch("host.worker.time.sleep"):
from host.worker import inspect
self.assertEqual(inspect("https://youtu.be/jNQXAC9IVRw")["id"], "jNQXAC9IVRw")
first.cookiejar.set_cookie.assert_not_called(); second.cookiejar.set_cookie.assert_not_called()
def test_browser_session_is_youtube_only_and_never_saved_to_a_cookie_file(self):
from urllib.request import Request
from yt_dlp import YoutubeDL
from yt_dlp.cookies import YoutubeDLCookieJar
cookie = {"name": "SAPISID", "value": "synthetic-cookie", "domain": ".youtube.com", "path": "/", "secure": True, "httpOnly": True}
with patch.object(YoutubeDLCookieJar, "save", side_effect=AssertionError("Cookies must stay in memory")):
with YoutubeDL({"quiet": True}) as ydl:
apply_cookies(ydl, [cookie])
self.assertIsNone(ydl.cookiejar.filename)
request = Request("https://www.youtube.com/watch?v=jNQXAC9IVRw")
ydl.cookiejar.add_cookie_header(request)
self.assertIn("SAPISID=synthetic-cookie", request.get_header("Cookie"))
unrelated = Request("https://www.google.com/")
ydl.cookiejar.add_cookie_header(unrelated)
self.assertIsNone(unrelated.get_header("Cookie"))
for invalid in [{**cookie, "domain": ".google.com"}, {**cookie, "domain": "youtube.com.evil.test"},
{**cookie, "value": "secret\r\nInjected: header"}, {**cookie, "expirationDate": float("nan")}, {**cookie, "name": "bad=name"}]:
with self.assertRaises(ValueError): validate_cookies([invalid])
def test_native_inspection_passes_cookies_only_to_worker_and_not_to_reply(self):
with tempfile.TemporaryDirectory() as folder, patch("host.main.ROOT", Path(folder)):
output = io.BytesIO(); host = NativeHost(output)
cookie = {"name": "SID", "value": "synthetic-session", "domain": ".youtube.com", "path": "/"}
try:
with patch.object(host, "worker", return_value={"title": "Test"}) as worker:
host.handle({"id": "inspect-auth", "action": "inspect", "payload": {"url": "https://youtu.be/jNQXAC9IVRw", "browserCookies": [cookie]}})
self.assertEqual(worker.call_args.args[1]["browserCookies"][0]["value"], cookie["value"])
self.assertNotIn(b"synthetic-session", output.getvalue())
self.assertNotIn("browserCookies", json.dumps(host.jobs))
finally: host.close()
def test_youtube_login_error_is_distinct_from_helper_connection_failure(self):
message = public_error(ValueError("ERROR: Sign in to confirm you’re not a bot"))
self.assertIn("Использовать вход в YouTube", message)
self.assertIn("Помощник работает", message)
self.assertNotIn("cookies-from-browser", message)
self.assertIn("отклонил вход", public_error(ValueError("Sign in to confirm"), True))
def test_youtube_urls_are_canonical_and_do_not_allow_arbitrary_hosts(self):
expected = "https://www.youtube.com/watch?v=jNQXAC9IVRw"
for value in [expected + "&list=other", "https://youtu.be/jNQXAC9IVRw?t=2", "https://www.youtube.com/shorts/jNQXAC9IVRw"]:
self.assertEqual(youtube_url(value), expected)
for value in ["https://youtube.com.evil.test/watch?v=jNQXAC9IVRw", "https://127.0.0.1/watch?v=jNQXAC9IVRw", "https://youtube.com@evil.test/watch?v=jNQXAC9IVRw", "file:///test", "https://www.youtube.com:444/watch?v=jNQXAC9IVRw", "https://www.youtube.com/@channel"]:
with self.assertRaises(ValueError): youtube_url(value)
def test_separate_audio_is_added_and_video_only_stays_single(self):
info = {"formats": [{"format_id": "18", "vcodec": "h264", "acodec": "aac", "height": 360},
{"format_id": "137", "vcodec": "h264", "acodec": "none", "height": 1080},
{"format_id": "140", "vcodec": "none", "acodec": "aac"}]}
base = {"mode": "video", "videoId": "137", "audioId": "best"}
self.assertEqual(select_streams(info, base), "137+140")
self.assertEqual(select_streams(info, {**base, "mode": "video_only"}), "137")
self.assertEqual(select_streams(info, {**base, "videoId": "18"}), "18")
self.assertEqual(select_streams(info, {**base, "mode": "audio"}), "140")
def test_download_does_not_accept_format_selector_injection(self):
with tempfile.TemporaryDirectory() as folder:
base = {"url": "https://youtu.be/jNQXAC9IVRw", "folder": folder, "videoId": "137+bestaudio"}
with self.assertRaises(ValueError): validate_download(base)
self.assertEqual(validate_download({**base, "videoId": "137"})["container"], "mp4")
with self.assertRaises(ValueError): validate_download({**base, "videoId": "137", "container": "exe"})
def test_gpu_is_used_only_when_video_needs_transcoding(self):
media = {"streams": [{"codec_type": "video", "codec_name": "vp9", "pix_fmt": "yuv420p"}, {"codec_type": "audio", "codec_name": "opus"}]}
options = {"mode": "video", "container": "mp4", "codec": "auto", "engine": "auto"}
plan = encoding_plan(options, media, ["h264", "hevc", "av1"])
self.assertEqual((plan["video"], plan["audio"], plan["engine"]), ("h264", "aac", "nvenc"))
copied = encoding_plan({**options, "container": "mkv"}, media, ["h264"])
self.assertEqual((copied["video"], copied["audio"], copied["engine"]), ("copy", "copy", "copy"))
cpu = encoding_plan(options, media, [])
self.assertEqual(cpu["engine"], "cpu")
with self.assertRaises(ValueError): encoding_plan({**options, "engine": "nvenc"}, media, [])
with self.assertRaises(ValueError): encoding_plan({**options, "codec": "copy"}, media, [])
def test_hdr_is_not_silently_converted_to_h264(self):
media = {"streams": [{"codec_type": "video", "codec_name": "vp9", "pix_fmt": "yuv420p10le", "color_transfer": "smpte2084"}, {"codec_type": "audio", "codec_name": "opus"}]}
options = {"mode": "video", "container": "mp4", "codec": "auto", "engine": "auto"}
self.assertEqual(encoding_plan(options, media, ["hevc"])["video"], "hevc")
with self.assertRaises(ValueError): encoding_plan({**options, "codec": "h264"}, media, ["h264"])
def test_amf_requires_measured_codec_and_10bit_support(self):
media = {"streams": [{"codec_type": "video", "codec_name": "vp9", "pix_fmt": "yuv420p"}, {"codec_type": "audio", "codec_name": "opus"}]}
options = {"mode": "video", "container": "mp4", "codec": "hevc", "engine": "auto", "preset": "balanced", "bitrate": 256}
self.assertEqual(encoding_plan(options, media, {"amf": ["hevc"]})["engine"], "amf")
self.assertEqual(encoding_plan(options, media, {"nvenc": ["hevc"], "amf": ["hevc"]})["engine"], "nvenc")
with self.assertRaisesRegex(ValueError, "AMD AMF"):
encoding_plan({**options, "engine": "amf"}, media, {})
media["streams"][0].update(pix_fmt="yuv420p10le", color_transfer="smpte2084")
self.assertEqual(encoding_plan(options, media, {"amf": ["hevc"]})["engine"], "cpu")
plan = encoding_plan({**options, "engine": "amf"}, media, {"amf": ["hevc"], "amf10bit": ["hevc"]})
self.assertTrue(plan["tenBit"])
with patch("host.media.tool", return_value="ffmpeg"):
command = ffmpeg_command("source.mkv", "out.mp4", options, plan)
self.assertIn("hevc_amf", command); self.assertIn("p010le", command); self.assertIn("main10", command)
self.assertIn("d3d11va", command); self.assertNotIn("cuda", command)
def test_audio_and_video_only_output_maps(self):
source = {"streams": [{"codec_type": "video", "codec_name": "h264"}, {"codec_type": "audio", "codec_name": "aac"}]}
options = {"mode": "video_only", "container": "mp4", "codec": "auto", "engine": "auto", "preset": "balanced", "bitrate": 256}
with patch("host.media.tool", return_value="ffmpeg"):
command = ffmpeg_command("source.mkv", "out.mp4", options, encoding_plan(options, source, []))
self.assertIn("-an", command)
self.assertNotIn("0:a:0", command)
audio = encoding_plan({**options, "mode": "audio", "container": "mp3"}, source, [])
self.assertEqual((audio["video"], audio["audio"], audio["engine"]), (None, "libmp3lame", "cpu"))
def test_protocol_uses_utf8_byte_lengths_and_handles_partial_reads(self):
message = {"text": "Русский путь 🎬"}
stream = io.BytesIO()
write_message(stream, message)
self.assertEqual(struct.unpack("<I", stream.getvalue()[:4])[0], len(stream.getvalue()) - 4)
class Partial(io.BytesIO):
def read(self, count=-1): return super().read(min(count, 2))
self.assertEqual(read_message(Partial(stream.getvalue())), message)
self.assertIsNone(read_message(io.BytesIO()))
with self.assertRaises(ValueError): read_message(io.BytesIO(struct.pack("<I", MAX_MESSAGE + 1)))
with self.assertRaises(EOFError): read_message(io.BytesIO(struct.pack("<I", 5) + b"{}"))
def test_filename_is_safe_and_existing_files_are_never_overwritten(self):
self.assertEqual(safe_filename("../CON:bad/name", "video"), "_CON_bad_name")
self.assertEqual(safe_filename("CON", "video"), "_CON")
with tempfile.TemporaryDirectory() as folder:
root = Path(folder); original = root / "video.mp4"; original.write_bytes(b"original")
source = root / "temp.mp4"; source.write_bytes(b"new")
saved = publish_file(source, root, "video", "mp4")
self.assertEqual(saved.name, "video (1).mp4")
self.assertEqual(original.read_bytes(), b"original")
self.assertEqual(saved.read_bytes(), b"new")
def test_dependency_install_refreshes_capabilities_before_reporting_completion(self):
with tempfile.TemporaryDirectory() as folder, patch("host.main.ROOT", Path(folder)):
host = NativeHost(io.BytesIO())
host._capabilities = {"ready": False}
host.jobs["install"] = {"id": "install", "status": "running", "kind": "dependency"}
try:
with patch.object(host, "worker", return_value={"title": "Node.js"}), patch.object(host, "event") as event:
event.side_effect = lambda *args, **kwargs: self.assertIsNone(host._capabilities)
host.run_job("install", {"dependency": "node"}, "dependency_install")
self.assertEqual(event.call_args.kwargs["status"], "complete")
with patch.object(host, "worker", side_effect=ValueError("install failed")), patch.object(host, "event") as event:
host._capabilities = {"ready": False}
event.side_effect = lambda *args, **kwargs: self.assertIsNone(host._capabilities)
host.run_job("install", {"dependency": "node"}, "dependency_install")
self.assertEqual(event.call_args.kwargs["status"], "error")
finally:
host.close()
def test_completed_file_can_be_revealed_after_native_host_restart(self):
with tempfile.TemporaryDirectory() as folder, patch("host.main.ROOT", Path(folder)):
saved = Path(folder) / "finished.mp4"
saved.write_bytes(b"media")
first = NativeHost(io.BytesIO())
first.jobs["completed"] = {"id": "completed", "status": "running", "createdAt": 1}
first.event("completed", status="complete", result={"path": str(saved)})
first.close()
output = io.BytesIO()
restarted = NativeHost(output)
try:
with patch("host.main.subprocess.Popen") as launch:
restarted.handle({"id": "reveal", "action": "reveal", "payload": {"jobId": "completed"}})
output.seek(0)
self.assertTrue(read_message(output)["ok"])
if os.name == "nt":
self.assertEqual(launch.call_args.args[0][-1], str(saved))
finally:
restarted.close()
if __name__ == "__main__": unittest.main()
+205
View File
@@ -0,0 +1,205 @@
import functools
import io
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest.mock import patch
from host.common import validate_download
from host.main import NativeHost
from host.media import NO_WINDOW, ROOT, probe, tool
from host.processes import ProcessTree
from host.worker import convert, download
class ProcessingTests(unittest.TestCase):
def test_audio_conversion_and_video_conversion_report_the_actual_engine(self):
runtime = ROOT / ".runtime"
runtime.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="processing-stages-", dir=runtime) as directory:
source = Path(directory) / "source.mkv"
subprocess.run([tool("ffmpeg"), "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=160x90:rate=24",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "3", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "libopus", str(source)],
creationflags=NO_WINDOW, stdin=subprocess.DEVNULL, check=True, timeout=30)
media = probe(source)
for codec, stage in [("auto", "audio"), ("h264", "encoding")]:
target = Path(directory) / f"{codec}.mp4"
events = []
options = {"mode": "video", "container": "mp4", "codec": codec, "engine": "auto" if codec == "auto" else "cpu", "preset": "fast", "bitrate": 192}
with patch("host.worker.emit", side_effect=lambda kind, **data: events.append({"type": kind, **data})):
plan = convert(source, target, options, media, {"nvenc": ["h264"]})
self.assertEqual(plan["engine"], "cpu")
self.assertEqual(plan["video"], "copy" if codec == "auto" else "h264")
self.assertTrue(any(event.get("stage") == stage and event.get("engine") == "cpu" for event in events), events)
self.assertFalse(any(event.get("stage") == "merging" for event in events), events)
self.assertTrue(any(event.get("stage") == "finalizing" and event.get("percent") == 99 and not event.get("speed") for event in events), events)
finalizing = next(index for index, event in enumerate(events) if event.get("stage") == "finalizing")
self.assertTrue(all(event.get("stage") == "finalizing" for event in events[finalizing:]), events)
self.assertEqual({s.get("codec_name") for s in probe(target)["streams"]}, {"h264", "aac"})
def test_late_completion_cannot_replace_cancellation(self):
host = NativeHost(io.BytesIO())
try:
host.jobs["late"] = {"id": "late", "status": "running", "stage": "cancelling", "cancelling": True}
host.event("late", status="complete", stage="complete")
self.assertEqual(host.jobs["late"]["status"], "running")
host.event("late", status="cancelled", stage="cancelled", cancelling=False)
host.event("late", status="complete", stage="complete")
self.assertEqual(host.jobs["late"]["status"], "cancelled")
finally: host.close()
def test_work_folder_validation_and_default(self):
with tempfile.TemporaryDirectory() as folder:
payload = {"url": "https://youtu.be/jNQXAC9IVRw", "folder": folder}
self.assertEqual(validate_download(payload)["workFolder"], str(Path(folder).resolve()))
with self.assertRaisesRegex(ValueError, "Рабочая папка"):
validate_download({**payload, "workFolder": str(Path(folder) / "missing")})
@unittest.skipUnless(os.name == "nt", "Native worker process and disk cleanup")
def test_native_worker_cancel_removes_both_workspaces_and_stops_ffmpeg(self):
runtime = ROOT / ".runtime"
runtime.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=runtime) as work, tempfile.TemporaryDirectory() as target:
fixture = Path(work) / "host"
fixture.mkdir()
(fixture / "worker.py").write_text('''import json,subprocess,sys,tempfile,time
from pathlib import Path
task=json.loads(sys.stdin.readline())['payload']
for folder in [task['workFolder'],task['folder']]:
scratch=Path(tempfile.mkdtemp(prefix='.ytdl-',dir=folder))
(scratch/'partial.mp4').write_bytes(b'partial')
print(json.dumps({'type':'workspace','path':str(scratch)}),flush=True)
child=subprocess.Popen([task['ffmpeg'],'-nostdin','-v','error','-re','-f','lavfi','-i','testsrc2=size=64x64:rate=10','-f','null','-'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print(json.dumps({'type':'progress','stage':'merging','percent':25,'testPid':child.pid}),flush=True)
time.sleep(60)
''', encoding="utf-8")
for folder in [work, target]: (Path(folder) / "keep.txt").write_text("user file")
with patch("host.main.ROOT", Path(work)):
host = NativeHost(io.BytesIO())
host._capabilities = {}
host.jobs["native"] = {"id": "native", "status": "running", "stage": "preparing"}
thread = threading.Thread(target=host.run_job, args=("native", {"folder": target, "workFolder": work, "ffmpeg": tool("ffmpeg")}))
thread.start()
try:
deadline = time.monotonic() + 10
while "testPid" not in host.jobs["native"] and time.monotonic() < deadline: time.sleep(0.025)
pid = host.jobs["native"]["testPid"]
host.handle({"id": "stop", "action": "cancel", "payload": {"jobId": "native"}})
thread.join(timeout=10)
self.assertFalse(thread.is_alive())
self.assertFalse(self.running(pid))
self.assertFalse(host.processes)
for folder in [work, target]:
self.assertFalse(list(Path(folder).glob(".ytdl-*")))
self.assertEqual((Path(folder) / "keep.txt").read_text(), "user file")
finally:
host.close()
thread.join(timeout=10)
@unittest.skipUnless(os.name == "nt", "Actual Windows process-tree regression")
def test_cancel_kills_ffmpeg_after_parent_exit_and_keeps_other_job(self):
launcher = """import json,subprocess,sys,time
task=json.loads(sys.stdin.readline())
child=subprocess.Popen([task['ffmpeg'],'-hide_banner','-loglevel','error','-nostdin','-re','-f','lavfi','-i','testsrc2=size=64x64:rate=10','-f','null','-'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print(child.pid,flush=True)
if not task['exit']:time.sleep(60)
"""
processes = []
host = NativeHost(io.BytesIO())
try:
children = []
for exits in (True, False):
p = ProcessTree.launch([sys.executable, "-c", launcher], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, creationflags=NO_WINDOW)
processes.append(p)
p.stdin.write(json.dumps({"ffmpeg": tool("ffmpeg"), "exit": exits}) + "\n")
p.stdin.close()
children.append(int(p.stdout.readline()))
if exits: p.wait(timeout=10)
host.jobs["cancel"] = {"id": "cancel", "status": "running", "stage": "merging"}
host.processes["cancel"] = processes[0]
self.assertTrue(self.running(children[0]))
self.assertTrue(self.running(children[1]))
host.handle({"id": "request", "action": "cancel", "payload": {"jobId": "cancel"}})
self.assertEqual(host.jobs["cancel"]["status"], "cancelled")
self.assertFalse(self.running(children[0]), "Cancelled FFmpeg must actually exit")
self.assertTrue(self.running(children[1]), "A different job's FFmpeg must survive")
finally:
host.close()
for p in processes:
tree = getattr(p, "ytdl_process_tree", None)
if tree:
tree.terminate(); tree.close()
elif p.poll() is None:
p.kill(); p.wait()
p.stdout.close()
for pid in locals().get("children", []):
if self.running(pid):
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=NO_WINDOW, timeout=10)
@staticmethod
def running(pid):
import ctypes
from ctypes import wintypes
api = ctypes.WinDLL("kernel32", use_last_error=True)
api.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
api.OpenProcess.restype = wintypes.HANDLE
api.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
api.WaitForSingleObject.restype = wintypes.DWORD
api.CloseHandle.argtypes = [wintypes.HANDLE]
handle = api.OpenProcess(0x100000, False, pid)
if not handle: return False
try: return api.WaitForSingleObject(handle, 0) == 258
finally: api.CloseHandle(handle)
def test_actual_download_merger_reports_progress_and_publishes_across_disks(self):
from yt_dlp import YoutubeDL
runtime = ROOT / ".runtime"
runtime.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="processing-test-", dir=runtime) as working, tempfile.TemporaryDirectory() as output:
work, destination = Path(working), Path(output)
video, audio = work / "video.mp4", work / "audio.m4a"
for inputs, target, codec in [(["-f", "lavfi", "-i", "testsrc2=size=160x90:rate=24"], video, ["-an", "-c:v", "libx264", "-preset", "ultrafast"]),
(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"], audio, ["-vn", "-c:a", "aac"])]:
subprocess.run([tool("ffmpeg"), "-nostdin", "-v", "error", "-y", *inputs, "-t", "12", *codec, str(target)],
creationflags=NO_WINDOW, stdin=subprocess.DEVNULL, check=True, timeout=30)
class Handler(SimpleHTTPRequestHandler):
def log_message(self, *args): pass
server = ThreadingHTTPServer(("127.0.0.1", 0), functools.partial(Handler, directory=str(work)))
threading.Thread(target=server.serve_forever, daemon=True).start()
events = []
origin = f"http://127.0.0.1:{server.server_port}/"
def extract(ydl, url, download=True):
return ydl.process_ie_result({"id": "jNQXAC9IVRw", "title": "Processing regression", "duration": 12,
"webpage_url": "https://youtu.be/jNQXAC9IVRw", "extractor": "fixture", "extractor_key": "Fixture", "formats": [
{"format_id": "137", "url": origin + "video.mp4", "ext": "mp4", "protocol": "http", "vcodec": "avc1", "acodec": "none", "height": 90, "width": 160, "fps": 24},
{"format_id": "140", "url": origin + "audio.m4a", "ext": "m4a", "protocol": "http", "vcodec": "none", "acodec": "mp4a.40.2", "abr": 128}]}, download=download)
try:
with patch.object(YoutubeDL, "extract_info", extract), patch("host.worker.emit", side_effect=lambda kind, **data: events.append({"type": kind, **data})):
result = globals()["download"]({"url": "https://youtu.be/jNQXAC9IVRw", "folder": str(destination),
"workFolder": str(work), "mode": "video", "container": "mp4", "codec": "copy"}, [])
finally:
server.shutdown(); server.server_close()
saved = Path(result["path"])
self.assertEqual(saved.parent, destination.resolve())
self.assertEqual({s["codec_type"] for s in probe(saved)["streams"]}, {"video", "audio"})
self.assertTrue(any(e.get("stage") == "merging" and e.get("processedSeconds", 0) > 0 and e.get("written", 0) > 0 and e.get("percent") is not None for e in events), events)
self.assertTrue(any(e.get("stage") == "remuxing" and e.get("engine") == "copy" for e in events), events)
stages = [e.get("stage") for e in events if e.get("type") == "progress"]
self.assertLess(stages.index("merging"), stages.index("remuxing"))
self.assertNotIn("merging", stages[stages.index("remuxing"):])
if work.stat().st_dev != destination.stat().st_dev:
self.assertTrue(any(e.get("stage") == "saving" and e.get("copied", 0) > 0 for e in events), events)
self.assertFalse(list(work.glob(".ytdl-*")))
self.assertFalse(list(destination.glob(".ytdl-*")))
self.assertEqual(result["size"], saved.stat().st_size)
if __name__ == "__main__": unittest.main()
+64
View File
@@ -0,0 +1,64 @@
import hashlib
import io
import tempfile
import unittest
import zipfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from host.main import NativeHost
from host.updates import extract_zip, fetch, python_release, trusted_url
class UpdateTests(unittest.TestCase):
def test_automatic_checks_wait_for_idle_and_respect_disabled_setting(self):
with tempfile.TemporaryDirectory() as temporary, patch("host.main.ROOT", Path(temporary)):
host = NativeHost(io.BytesIO())
try:
with patch("host.main.installation_root", return_value=Path(temporary)), patch.object(host, "schedule_updates"), \
patch("host.updates.settings", return_value={"enabled": True}) as settings, patch("host.updates.due", return_value=True), \
patch("host.updates.check_updates", return_value=[]) as check:
host.jobs["video"] = {"status": "running"}
host.auto_check(); check.assert_not_called()
host.jobs.clear(); host.auto_check(); check.assert_called_once()
settings.return_value = {"enabled": False}; check.reset_mock()
host.auto_check(); check.assert_not_called()
finally: host.close()
def test_vendor_urls_and_archive_paths_are_restricted(self):
trusted_url("https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip")
for url in ("http://nodejs.org/file", "https://nodejs.org.evil.test/file", "https://name:secret@nodejs.org/file"):
with self.assertRaises(ValueError): trusted_url(url)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for name in ("../outside.exe", "C:/outside.exe", "runtime/../../outside.exe"):
archive = root / "bad.zip"
with zipfile.ZipFile(archive, "w") as package: package.writestr(name, b"bad")
with self.assertRaises(ValueError): extract_zip(archive, root / "component")
self.assertFalse((root / "outside.exe").exists())
def test_modified_download_is_rejected_before_use(self):
class Response:
headers = {"Content-Length": "8"}
def __enter__(self): return self
def __exit__(self, *args): return False
def iter_content(self, count): yield b"modified"
with tempfile.TemporaryDirectory() as temporary, patch("host.updates.get", return_value=Response()):
item = {"url": "https://nodejs.org/file", "sha256": hashlib.sha256(b"original").hexdigest()}
with self.assertRaisesRegex(ValueError, "Контрольная сумма"):
fetch(item, Path(temporary) / "download.zip", lambda _: None)
def test_python_uses_official_hash_and_stays_in_compatible_branch(self):
checksum = "a" * 64
index = '<a href="3.14.0/">3.14</a><a href="3.13.16/">16</a><a href="3.13.15/">15</a><a href="3.13.14/">14</a>'
def response(url):
if url.endswith("/ftp/python/"): return SimpleNamespace(text=index)
if "31316" in url: return SimpleNamespace(text="<tr><td>Source only</td></tr>")
return SimpleNamespace(text=f'<tr><td><a href="python-3.13.15-embed-amd64.zip">Windows embed</a></td><td><code>{checksum}</code></td></tr>')
with patch("host.updates.sys.version_info", SimpleNamespace(major=3, minor=13)), patch("host.updates.sys.version", "3.13.14"), patch("host.updates.get", side_effect=response):
result = python_release()
self.assertEqual(result["version"], "3.13.15"); self.assertEqual(result["sha256"], checksum)
if __name__ == "__main__": unittest.main()
+139
View File
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport: { width: 1200, height: 800 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
const poster = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="330" height="186"><defs><linearGradient id="g"><stop stop-color="#096574"/><stop offset="1" stop-color="#192636"/></linearGradient></defs><path fill="url(#g)" d="M0 0h330v186H0z"/></svg>');
await context.route("https://www.youtube.com/**", route => route.fulfill({ contentType: "text/html", body: `<!doctype html><html dark><head><meta charset="utf-8"><style>
body{margin:24px;background:#0f0f0f;color:#eee;font:16px/1.4 Arial}main{display:flex;gap:26px;align-items:flex-start}.card{display:block;width:330px;position:relative}a{color:inherit;text-decoration:none}img,video{display:block;width:100%;height:auto;aspect-ratio:330/186;object-fit:cover;border-radius:12px}a.title{display:block;font-weight:600;min-height:48px;margin-top:10px}.views{color:#aaa;margin-top:5px}ytd-thumbnail,yt-thumbnail-view-model{display:block}.short-image{display:block}.legacy-image{display:block;padding-bottom:28px}.modern-image{display:block;padding-bottom:20px}.scaled{transform:scale(.85);transform-origin:top left}.bordered{border:3px solid #333;padding:7px;box-sizing:border-box}
</style></head><body><main>
<ytm-shorts-lockup-view-model id="short-card" class="card"><a class="short-image" href="/shorts/Mf9GCn_LsUI"><yt-thumbnail-view-model><img src="${poster}" alt="Shorts preview"></yt-thumbnail-view-model></a><a class="title" href="/shorts/Mf9GCn_LsUI">ТЫ НЕ ВСТРЕЧАЛ этого Левиафана в Subnautica 2...</a><div class="views">1,6 млн просмотров</div></ytm-shorts-lockup-view-model>
<div id="legacy-card" class="card"><ytd-thumbnail><a id="thumbnail" class="legacy-image" href="/watch?v=jNQXAC9IVRw"><img src="${poster}" alt="Video preview"></a></ytd-thumbnail><div class="title">S.T.A.L.K.E.R. — видео и описание</div></div>
<yt-lockup-view-model id="modern-card" class="card scaled"><a class="yt-lockup-view-model__content-image modern-image" href="/watch?v=aircAruvnKk"><yt-thumbnail-view-model class="bordered"><img src="${poster}" alt="Modern preview"></yt-thumbnail-view-model><span>Название внутри ссылки</span></a><div class="views">Информация под превью</div></yt-lockup-view-model>
</main></body></html>` }));
const page = await context.newPage();
const errors = []; page.on("pageerror", error => errors.push(error.message));
async function assertInside(id) {
const geometry = await page.locator(`#${id}`).evaluate(card => {
const owner = card.dataset.ytdlPreview || card.querySelector('[data-ytdl-preview]')?.dataset.ytdlPreview;
const host = document.querySelector(`[data-ytdl-preview-for="${owner}"]`);
const media = card.querySelector('video, img');
const box = value => ({ left:value.left, top:value.top, right:value.right, bottom:value.bottom });
return { media: box(media.getBoundingClientRect()), buttons: [...host.shadowRoot.querySelectorAll('button')].map(button => box(button.getBoundingClientRect())), count: document.querySelectorAll(`[data-ytdl-preview-for="${owner}"]`).length };
});
assert.equal(geometry.count, 1, `Only one control pair in ${id}`);
for (const button of geometry.buttons) {
assert.ok(button.left >= geometry.media.left + 3 && button.right <= geometry.media.right - 3 && button.top >= geometry.media.top + 3 && button.bottom <= geometry.media.bottom - 3,
`Controls must stay inside ${id}'s actual picture: ${JSON.stringify(geometry)}`);
}
}
try {
await page.goto("https://www.youtube.com/");
await page.addScriptTag({ path: path.join(root, "extension/controls.js") });
for (const id of ["short-card", "legacy-card", "modern-card"]) {
await page.locator(`#${id} [data-ytdl-preview], #${id}[data-ytdl-preview]`).waitFor(); await assertInside(id);
}
// The real YouTube app is absolutely positioned: BODY has zero height, and
// its scroll/auto overflow is propagated to the viewport, not a zero-size clip.
await page.evaluate(() => {
document.querySelector('main').style.position='absolute';
document.body.style.overflowY='scroll'; document.body.style.overflowX='auto';
});
await page.waitForTimeout(100);
const shortOwner=await page.locator('#short-card').getAttribute('data-ytdl-preview');
assert.equal(await page.locator(`[data-ytdl-preview-for="${shortOwner}"]`).isVisible(),true,'Viewport overflow must not hide buttons when BODY has zero height');
await assertInside('short-card');
await page.screenshot({ path: path.join(artifacts, "thumbnail-placement.png"), animations: "disabled" });
await page.evaluate(() => {
const card = document.querySelector('#short-card');
card.addEventListener('mouseenter', () => {
const replacement = document.createElement('a'); replacement.href = '/shorts/Mf9GCn_LsUI'; replacement.className = 'short-image';
replacement.innerHTML = '<video style="background:#123b47"></video>';
card.querySelector('.short-image').replaceWith(replacement);
const player = document.createElement('div'); player.className = 'hover-player';
player.style.cssText = 'position:absolute;inset:0 0 auto;height:186px;background:#123b47;z-index:30;pointer-events:auto;border-radius:12px'; card.append(player);
}, { once:true });
});
await page.locator('#short-card').hover();
await page.locator('#short-card .hover-player').waitFor();
await page.waitForTimeout(250);
await assertInside('short-card');
const shortKey = await page.locator('#short-card').getAttribute('data-ytdl-preview');
const shortButtons = page.locator(`[data-ytdl-preview-for="${shortKey}"]`);
await shortButtons.locator('.download').click();
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), 'https://www.youtube.com/watch?v=Mf9GCn_LsUI');
assert.equal(page.url(), 'https://www.youtube.com/');
await page.screenshot({ path: path.join(artifacts, 'thumbnail-hover-placement.png'), animations:'disabled' });
// YouTube can hide the original card while rendering its hover player in a
// separate overlay. Controls must be outside that opacity/visibility subtree.
await page.evaluate(() => document.querySelector('#short-card').style.opacity = '0');
assert.equal(await shortButtons.evaluate(host => host.parentElement === document.documentElement), true);
assert.equal(await shortButtons.locator('.download').evaluate(button => Number(getComputedStyle(button).opacity)), 1);
await shortButtons.locator('.download').click();
await page.evaluate(() => {
document.querySelector('#short-card').style.opacity = '';
document.querySelector('#short-card .short-image').style.visibility = 'hidden';
});
await page.waitForTimeout(250);
assert.equal(await shortButtons.isVisible(), true);
await shortButtons.locator('.download').click();
await page.evaluate(() => document.querySelector('#short-card .short-image').style.visibility = '');
// Hover playback can remove the entire image link, leaving only metadata and
// a separate player. That metadata must not move the controls into the title.
const beforeRemoval = await shortButtons.boundingBox();
await page.evaluate(() => {
const link = document.querySelector('#short-card .short-image');
window.__removedThumbnail = link; link.remove();
});
await page.waitForTimeout(250);
assert.deepEqual(await shortButtons.boundingBox(), beforeRemoval);
await shortButtons.locator('.download').click();
await page.evaluate(() => document.querySelector('#short-card').prepend(window.__removedThumbnail));
// Layout changes without a window resize: columns narrower, title grows,
// and a lazy image gets its final height after initial attachment.
await page.evaluate(() => {
for (const card of document.querySelectorAll('.card')) card.style.width = '260px';
document.querySelector('#short-card .title').style.minHeight = '110px';
document.querySelector('#short-card .hover-player').style.height = '146.5px';
document.querySelector('#legacy-card img').style.aspectRatio = '4/3';
});
await page.waitForTimeout(300);
for (const id of ['short-card','legacy-card','modern-card']) await assertInside(id);
await page.evaluate(() => {
document.querySelector('#short-card .short-image').href = '/shorts/jNQXAC9IVRw';
document.querySelector('#short-card .title').href = '/shorts/jNQXAC9IVRw';
});
await shortButtons.locator('.download').click();
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), 'https://www.youtube.com/watch?v=jNQXAC9IVRw');
await page.evaluate(() => { document.body.style.minHeight = '1500px'; });
await page.setViewportSize({width:1200,height:300});
await page.evaluate(() => window.scrollTo(0,60));
await page.waitForTimeout(100);
await assertInside('short-card');
// A horizontally clipped Shorts shelf must not leave buttons floating over
// content outside the visible shelf.
await page.evaluate(() => {
const card = document.querySelector('#short-card');
const shelf = document.createElement('div'); shelf.id = 'short-shelf';
shelf.style.cssText = 'width:260px;overflow:hidden;flex-shrink:0';
card.before(shelf); shelf.append(card); card.style.marginLeft = '300px';
});
await page.waitForTimeout(100);
assert.equal(await shortButtons.isVisible(),false);
await page.evaluate(() => document.querySelector('#short-card').style.marginLeft='0');
await page.waitForTimeout(100);
assert.equal(await shortButtons.isVisible(),true);
await page.evaluate(() => document.querySelector('#short-card').remove());
await shortButtons.waitFor({state:'detached'});
assert.deepEqual(errors, []);
console.log('Thumbnail placement passed: real YouTube zero-height BODY, image bounds, title links, padding, borders, scaled layout, replaced/hidden/removed hover previews, responsive/lazy sizing, scroll, clipped shelves and recycled URLs.');
} finally { await browser.close(); }
+73
View File
@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createUiConnection } from "../extension/ui-connection.js";
function harness() {
const ports = [], events = [];
const runtime = { connect() {
const messages = [], disconnects = [];
const port = { sent: [], dead: false,
onMessage: { addListener: listener => messages.push(listener) },
onDisconnect: { addListener: listener => disconnects.push(listener) },
postMessage(message) {
if (this.dead) throw new Error("Attempting to use a disconnected port object");
this.sent.push(message);
},
respond(result, index = this.sent.length - 1) { messages.forEach(listener => listener({ id: this.sent[index].id, ok: true, result })); },
emit(message) { messages.forEach(listener => listener(message)); },
disconnect() { this.dead = true; disconnects.forEach(listener => listener()); },
};
ports.push(port); return port;
} };
const connection = createUiConnection({ runtime, onEvent: event => events.push(event), onDisconnect: () => events.push({ disconnected: true }) });
return { ...connection, ports, events, runtime };
}
test("a menu reconnects after worker shutdown, without resending an in-flight operation", async () => {
const h = harness();
const pending = h.call("helper_remove");
const rejected = assert.rejects(pending, /Связь с расширением прервана/);
const old = h.ports[0]; old.disconnect(); await rejected;
const next = h.call("helper_start");
assert.equal(h.ports.length, 2); assert.equal(old.sent.length, 1);
assert.equal(h.ports[1].sent[0].action, "helper_start");
h.ports[1].respond({ ready: true }); assert.deepEqual(await next, { ready: true });
// Late events from the old worker must not change the new status.
const count = h.events.length;
old.emit({ event: "helper", helper: { status: "missing" } }); old.disconnect();
assert.equal(h.events.length, count);
h.ports[1].emit({ event: "helper", helper: { status: "running" } });
assert.equal(h.events.at(-1).helper.status, "running");
});
test("a disconnected-port exception before its callback reconnects in the same click", async () => {
const h = harness();
const initial = h.call("hello"); h.ports[0].respond({ ready: true }); await initial;
h.ports[0].dead = true;
const retry = h.call("helper_start");
assert.equal(h.ports.length, 2);
h.ports[0].disconnect();
h.ports[1].respond({ ready: true }); assert.equal((await retry).ready, true);
assert.equal(h.ports[0].sent.length, 1, "Failed post was never delivered");
});
test("repeated disconnected-port failures stop after one retry with a readable error", async () => {
let attempts = 0;
const h = createUiConnection({ runtime: { connect() {
attempts++;
return { onMessage: { addListener() {} }, onDisconnect: { addListener() {} },
postMessage() { throw new Error("Attempting to use a disconnected port object"); } };
} } });
await assert.rejects(h.call("helper_start"), /Проверить подключение/);
assert.equal(attempts, 2);
});
test("helper errors are delivered once and do not replay updates or removal", async () => {
for (const action of ["helper_update", "helper_remove", "download"]) {
const h = harness(), result = h.call(action);
const rejected = assert.rejects(result, /Дождитесь завершения/);
h.ports[0].emit({ id: h.ports[0].sent[0].id, ok: false, error: "Дождитесь завершения загрузок" });
await rejected;
assert.equal(h.ports.length, 1); assert.equal(h.ports[0].sent.length, 1);
}
});
+100
View File
@@ -0,0 +1,100 @@
// Deterministic browser APIs for layout and interaction tests, without touching browser accounts.
(() => {
const listeners = [];
const contentListeners = [];
const disconnectListeners = [];
let sessionEnabled = false;
const fixtureStorage = { preferences: {}, onboarding: { state: "complete", version: 1 } };
window.__fixtureStorage = fixtureStorage;
window.__downloadRequests = [];
window.__quickRequests = [];
window.__emitJob = job => {
fixtureStorage.jobs ||= {};
fixtureStorage.jobs[job.id] = job;
listeners.forEach(listener => listener({ event: "job", job: fixtureStorage.jobs[job.id] }));
};
window.__historyRequests = [];
window.__uiDisconnect = () => disconnectListeners.forEach(listener => listener());
window.__contentMessage = message => contentListeners.forEach(listener => listener(message));
let autoUpdate = true;
window.__updateRequests = [];
const hardware = () => ({ version: window.__helperVersion || "1.3.2", bundle: { mode: "autonomous", version: window.__helperVersion || "1.3.2", updateTogether: true }, supportsComponentUpdates: true, supportsWorkFolder: true, updateSettings: { enabled: autoUpdate },
gpu: window.__amdOnly ? "AMD Radeon RX 7900 XTX" : "NVIDIA GeForce RTX 5080", nvenc: window.__dependencyMissing || window.__amdOnly ? [] : ["h264", "hevc", "av1"],
amf: window.__amdOnly ? ["h264", "hevc", "av1"] : [], amf10bit: window.__amdOnly ? ["hevc", "av1"] : [], defaultFolder: "D:\\Videos", ready: !window.__dependencyMissing,
dependencies: ["python", "yt_dlp", "ejs", "node", "ffmpeg"].map(id => ({ id, status: window.__dependencyMissing && id === "ffmpeg" ? "missing" : "installed", version: "test-version" })) });
const metadata = { id: "aircAruvnKk", url: "https://www.youtube.com/watch?v=aircAruvnKk",
title: "But what is a neural network? | Deep learning chapter 1", channel: "3Blue1Brown", duration: 1148,
videos: [
{ id: "401", height: 2160, fps: 60, codec: "av01.0.13M.08", extension: "mp4", hasAudio: false, size: 350000000, hdr: "SDR" },
{ id: "137", height: 1080, fps: 30, codec: "avc1.640028", extension: "mp4", hasAudio: false, size: 100000000, hdr: "SDR" },
{ id: "18", height: 360, fps: 30, codec: "avc1.42001E", extension: "mp4", hasAudio: true, size: 35000000, hdr: "SDR" },
], audios: [{ id: "251", codec: "opus", extension: "webm", bitrate: 160, language: "en", note: "English", size: 5000000 }] };
window.chrome = {
runtime: {
getManifest: () => ({ version: window.__extensionVersion || "1.4.5" }),
getURL: file => `https://ui.ytdl.test/${file}`,
sendMessage: async message => {
if (message.type === "QUICK_DOWNLOAD") { window.__quickRequests.push(message.url); return { ok: true, jobId: "fixture-job" }; }
if (message.type === "CLOSE_MENU") parent.postMessage({ type: "FIXTURE_CLOSE_MENU" }, "https://www.youtube.com");
return { tabId: 42, jobs: [] };
},
onMessage: { addListener: listener => contentListeners.push(listener) },
connect: () => ({
onMessage: { addListener: listener => listeners.push(listener) },
onDisconnect: { addListener: listener => disconnectListeners.push(listener) },
postMessage: message => {
let result;
if ((message.action === "hello" || message.action === "helper_start") && window.__helperMissing) {
setTimeout(() => {
listeners.forEach(listener => listener({ event: "helper", helper: { status: "missing", message: "Помощник не установлен. Скачайте установщик." } }));
listeners.forEach(listener => listener({ id: message.id, ok: false, error: "Помощник не установлен." }));
}, 5);
return;
}
if (message.action === "hello" || message.action === "helper_start") result = hardware();
if (message.action === "helper_status") result = { status: "running", hardware: hardware() };
if (message.action === "update_settings") { autoUpdate = message.payload.enabled; result = { enabled: autoUpdate }; }
if (message.action === "helper_update") {
window.__updateRequests.push(message.payload); result = { jobId: "fixture-update" };
setTimeout(() => window.__emitJob({ id: "fixture-update", kind: "update", canCancel: false, title: "Обновление компонентов", status: "running", stage: "updating", percent: 36, message: "Скачиваю FFmpeg…" }), 10);
}
if (message.action === "dependency_install") {
result = { jobId: "fixture-dependency" };
setTimeout(() => {
window.__dependencyMissing = false;
listeners.forEach(listener => listener({ event: "helper", helper: { status: "running", hardware: hardware() } }));
}, 30);
}
if (message.action === "helper_remove") {
result = { removed: true };
listeners.forEach(listener => listener({ event: "helper", helper: { status: "missing", message: "Помощник удалён." } }));
}
if (message.action === "session_status") result = { status: sessionEnabled ? "connected" : "disabled", message: "Вход текущего браузера" };
if (message.action === "session_enable") { sessionEnabled = message.payload.enabled; result = { status: sessionEnabled ? "connected" : "disabled" }; }
if (message.action === "inspect") {
if (window.__requiresLogin && !sessionEnabled) {
setTimeout(() => listeners.forEach(listener => listener({id:message.id,ok:false,error:"ERROR: Sign in to confirm you’re not a bot. Use --cookies for authentication"})),5);
return;
}
result = metadata;
}
if (message.action === "pick_folder") result = { folder: "D:\\Videos\\YouTube" };
if (message.action === "open_history") { window.__historyRequests.push(message.payload); result = { opened: true }; }
if (message.action === "download") { window.__downloadRequests.push(message.payload); result = { jobId: "fixture-job" }; }
if (message.action === "state" || message.action === "jobs") result = { jobs: Object.values(fixtureStorage.jobs || {}) };
setTimeout(() => listeners.forEach(listener => listener({ id: message.id, ok: true, result })), 5);
},
}),
},
storage: { local: {
get: async keys => {
const data = { ...fixtureStorage };
if (window.__firstRun && !window.__onboardingStored) delete data.onboarding;
return Object.fromEntries((Array.isArray(keys) ? keys : [keys]).filter(key => key in data).map(key => [key, structuredClone(data[key])]));
},
set: async data => { Object.assign(fixtureStorage, structuredClone(data)); if (data.onboarding) window.__onboardingStored = true; },
} },
permissions: { request: async () => window.__denySessionPermission !== true },
};
window.__helperOffline = () => listeners.forEach(listener => listener({ event: "helper", helper: { status: "offline", message: "Помощник отключён." } }));
})();
+239
View File
@@ -0,0 +1,239 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const artifacts = path.join(root, "test-artifacts");
await fs.mkdir(artifacts, { recursive: true });
const browser = await chromium.launch({ headless: true,
executablePath: process.env.YTDL_TEST_BROWSER || "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport: { width: 1180, height: 860 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await context.route("**/*", async route => {
const url = new URL(route.request().url());
if (url.hostname === "ui.ytdl.test") {
const name = url.pathname.slice(1).split("/").pop();
if (!["downloader.html", "styles.css", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "shared.js", "session.js"].includes(name)) return route.abort();
const type = name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript";
return route.fulfill({ body: await fs.readFile(path.join(root, "extension", name)), contentType: type });
}
if (url.hostname === "www.youtube.com") return route.fulfill({ contentType: "text/html", body: `<!doctype html><html dark><head><meta charset="utf-8"><style>body{margin:0;background:#0f0f0f;color:#eee;font:14px "Segoe UI",sans-serif}main{width:780px;margin:35px}.player{height:330px;background:linear-gradient(135deg,#162c43,#0a1421);border-radius:15px;display:grid;place-items:center;color:#65849c;font-size:27px}h1{font-size:22px;margin:22px 0}#owner{display:flex;align-items:center;gap:9px}.channel{margin-right:12px}.channel b{display:block}.channel small{color:#999}#subscribe-button button{border:0;border-radius:22px;background:#eee;padding:11px 17px;color:#111;font-weight:600}.note{color:#8d98a6;margin-top:35px}.avatar{width:38px;height:38px;background:#577295;border-radius:50%}</style></head><body><main><div class="player">Deep learning · chapter 1</div><h1>But what is a neural network?</h1><ytd-watch-metadata><div id="owner"><span class="avatar"></span><span class="channel"><b>3Blue1Brown</b><small>Наглядная математика</small></span><div id="subscribe-button"><button>Подписаться</button></div></div></ytd-watch-metadata><p class="note">Тест интерфейса YouTubeDL</p></main></body></html>` });
return route.abort();
});
const page = await context.newPage();
const errors = [];
page.on("pageerror", error => errors.push(error.message));
try {
await page.goto("https://www.youtube.com/watch?v=aircAruvnKk");
await page.addScriptTag({ path: path.join(root, "extension/controls.js") });
await page.addScriptTag({ path: path.join(root, "extension/content.js") });
await page.evaluate(() => window.addEventListener("message", event => {
if (event.origin === "https://ui.ytdl.test" && event.data?.type === "FIXTURE_CLOSE_MENU") window.__contentMessage({ type: "CLOSE_MENU" });
}));
assert.equal(await page.locator("#subscribe-button + #ytdl-subscribe-download").count(), 1, "Button must be next to Subscribe");
const siteDownload = page.locator("#ytdl-subscribe-download .download");
assert.equal(await siteDownload.locator("svg").count(), 1, "Keep the site download icon");
const beforeHover = await siteDownload.boundingBox();
await siteDownload.hover();
assert.deepEqual(await siteDownload.boundingBox(), beforeHover, "The site button must not move on hover");
await siteDownload.click();
assert.equal(await page.locator("#ytdl-menu").count(), 0, "Download starts directly without a choice menu");
assert.deepEqual(await page.evaluate(() => window.__quickRequests), ["https://www.youtube.com/watch?v=aircAruvnKk"]);
await page.locator("#ytdl-subscribe-download .settings").click();
const frame = page.frameLocator("#ytdl-menu iframe");
await frame.locator("#download-form").waitFor({ state: "visible" });
assert.equal(await frame.locator("#download svg").count(), 0);
assert.ok(!(await frame.locator("#download").textContent()).includes("↓"));
assert.equal(await frame.locator("#helper-status").textContent(), "Помощник работает");
assert.equal(await frame.locator('.dependency-row[data-status="installed"]').count(), 5);
assert.equal(await frame.locator("#quality option").count(), 4);
await frame.locator("#quality").selectOption("137");
const actionSizes = await frame.locator('.form-actions > button').evaluateAll(buttons => buttons.map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height, font: getComputedStyle(button).fontSize })));
assert.ok(Math.abs(actionSizes[0].width - actionSizes[1].width) < 1);
assert.equal(actionSizes[0].height, actionSizes[1].height); assert.equal(actionSizes[0].font, actionSizes[1].font);
await frame.locator('.form-actions').screenshot({path:path.join(artifacts,'form-actions.png')});
await frame.locator('body').evaluate(() => window.__emitJob({ id: 'old-download', title: 'Сохранённый ролик', status: 'complete', stage: 'complete', result: {path:'D:/Videos/old.mp4'}, createdAt: 1 }));
assert.equal(await frame.locator('[data-job-id="old-download"]').count(), 0, 'Completed history moves to its own page');
await frame.locator('#open-history').click();
await frame.locator('body').evaluate(() => {
if (window.__historyRequests.length !== 1) throw new Error('History button must open a new tab');
});
await frame.locator('body').evaluate(() => window.__emitJob({id:'active-download',title:'Текущая загрузка',status:'running',stage:'downloading',percent:12,createdAt:2}));
assert.equal(await frame.locator('#active-jobs .job-card').isVisible(), true);
await frame.locator('body').evaluate(() => window.__emitJob({id:'active-download',title:'Текущая загрузка',status:'complete',stage:'complete',result:{path:'D:/Videos/new.mp4'},createdAt:2}));
assert.equal(await frame.locator('#active-jobs-section').isVisible(), false);
assert.equal(await frame.locator('#open-history').isVisible(), true);
await frame.locator("html[data-theme=dark]").waitFor();
await frame.locator("body").evaluate(() => { for (const animation of document.getAnimations()) if (Number.isFinite(animation.effect.getTiming().iterations)) animation.finish(); });
await page.screenshot({ path: path.join(artifacts, "menu-dark.png"), animations: "disabled" });
await frame.locator(".mode-switch label").filter({ hasText: /^Аудио$/ }).click();
assert.equal(await frame.locator("#video-options").isVisible(), false);
assert.equal(await frame.locator("#container option").count(), 5);
await frame.locator("#container").selectOption("mp3");
await frame.locator("#pick-folder").click();
await frame.locator("#pick-folder:not([disabled])").waitFor();
assert.equal(await frame.locator("#folder").inputValue(), "D:\\Videos\\YouTube");
await frame.locator("#download").click();
await page.locator("#ytdl-menu").waitFor({ state: "detached" });
const job = { id: "fixture-job", title: "But what is a neural network?", status: "running", stage: "encoding", engine: "nvenc", percent: 64, speed: "12.8x" };
await page.evaluate(job => window.__contentMessage({ type: "JOB_PROGRESS", job }), job);
await page.locator("#ytdl-notifications .toast").waitFor();
assert.match(await page.locator("#ytdl-notifications .stage").textContent(), /NVENC/);
await page.screenshot({ path: path.join(artifacts, "progress-toast.png"), animations: "disabled" });
await page.evaluate(() => { document.documentElement.removeAttribute("dark"); document.body.style.background = "#f9fafc"; document.body.style.color = "#182234"; });
await page.locator("#ytdl-notifications:not([dark])").waitFor();
await page.locator("#ytdl-subscribe-download .settings").click();
const lightFrame = page.frameLocator("#ytdl-menu iframe");
await lightFrame.locator("#download-form").waitFor({ state: "visible" });
assert.equal(await lightFrame.locator("html").getAttribute("data-theme"), "light");
await page.screenshot({ path: path.join(artifacts, "menu-light.png"), animations: "disabled" });
await page.setViewportSize({ width: 340, height: 720 });
const box = await page.locator("#ytdl-menu").boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= 340, "Menu must stay inside a narrow viewport");
await lightFrame.locator("#download-form").waitFor({ state: "visible" });
const overflow = await lightFrame.locator("html").evaluate(node => node.scrollWidth > node.clientWidth);
assert.equal(overflow, false, "No horizontal overflow");
await lightFrame.locator("html").evaluate(() => window.__helperOffline());
assert.equal(await lightFrame.locator("#helper-status").textContent(), "Помощник отключён");
assert.equal(await lightFrame.locator('.dependency-row[data-status="unknown"]').count(), 5);
await lightFrame.locator("#helper-start").click();
await lightFrame.locator("#download-form").waitFor({ state: "visible" });
assert.equal(await lightFrame.locator("#helper-status").textContent(), "Помощник работает");
await lightFrame.locator("#close").click();
await page.locator("#ytdl-menu").waitFor({ state: "detached" });
await page.evaluate(() => {
const thumbnail = document.createElement("ytd-thumbnail");
thumbnail.innerHTML = '<a id="thumbnail" href="/watch?v=jNQXAC9IVRw" style="display:block;width:260px;height:146px;background:#30506b"><img alt="Test video" style="width:100%;height:100%"></a>';
document.querySelector("main").prepend(thumbnail);
});
await page.locator(".ytdl-thumbnail-download .download").click();
assert.equal(page.url(), "https://www.youtube.com/watch?v=aircAruvnKk", "A thumbnail download must not navigate");
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
await page.locator(".ytdl-thumbnail-download .settings").click();
await page.frameLocator("#ytdl-menu iframe").locator("#download-form").waitFor({ state: "visible" });
assert.equal(new URL(await page.locator("#ytdl-menu iframe").getAttribute("src")).searchParams.get("url"), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
await page.frameLocator('#ytdl-menu iframe').locator('#download-form').waitFor({state:'visible'});
await page.frameLocator('#ytdl-menu iframe').locator('#folder').press('Escape');
await page.locator('#ytdl-menu').waitFor({state:'detached'});
await page.evaluate(() => {
const preview = document.createElement('ytm-shorts-lockup-view-model');
preview.style.cssText='display:block;width:120px;height:230px;position:relative';
preview.innerHTML = '<a href="/shorts/Mf9GCn_LsUI" style="display:block;height:200px"><yt-thumbnail-view-model><img alt="Short" style="width:120px;height:200px"></yt-thumbnail-view-model></a>';
preview.addEventListener('mouseenter', () => {
const replacement=document.createElement('a'); replacement.href='/shorts/Mf9GCn_LsUI'; replacement.style.cssText='display:block;height:200px';
replacement.innerHTML='<video style="width:120px;height:200px;background:#24364c"></video>';
preview.querySelector('a').replaceWith(replacement);
const player=document.createElement('div'); player.className='hover-player'; player.style.cssText='position:absolute;inset:0 0 30px;z-index:30;background:#24364c'; preview.append(player);
}, {once:true});
document.querySelector('main').prepend(preview);
});
await page.locator('ytm-shorts-lockup-view-model[data-ytdl-preview]').waitFor();
await page.locator('ytm-shorts-lockup-view-model').hover();
await page.locator('ytm-shorts-lockup-view-model .hover-player').waitFor();
const shortKey=await page.locator('ytm-shorts-lockup-view-model').getAttribute('data-ytdl-preview');
const shortButtons=page.locator(`[data-ytdl-preview-for="${shortKey}"]`);
assert.equal(await shortButtons.evaluate(node => node.parentElement === document.documentElement), true);
await shortButtons.locator('.download').click();
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), 'https://www.youtube.com/watch?v=Mf9GCn_LsUI');
await shortButtons.locator('.settings').click();
await page.frameLocator('#ytdl-menu iframe').locator('#download-form').waitFor({state:'visible'});
await page.frameLocator('#ytdl-menu iframe').locator('#close').click();
await page.locator('#ytdl-menu').waitFor({state:'detached'});
await page.evaluate(() => {
history.pushState({}, "", "/shorts/jNQXAC9IVRw");
document.body.innerHTML = '<ytd-shorts><ytd-reel-video-renderer is-active><div id="actions" style="position:fixed;right:20px;bottom:180px"></div></ytd-reel-video-renderer></ytd-shorts>';
document.dispatchEvent(new Event("yt-navigate-finish"));
});
await page.locator("#actions #ytdl-shorts-download .download").click();
assert.equal(await page.evaluate(() => window.__quickRequests.at(-1)), "https://www.youtube.com/watch?v=jNQXAC9IVRw");
await page.locator("#ytdl-shorts-download .settings").click();
await page.frameLocator("#ytdl-menu iframe").locator("#download-form").waitFor({ state: "visible" });
await page.screenshot({ path: path.join(artifacts, "shorts-settings.png"), animations: "disabled" });
await page.evaluate(() => window.__contentMessage({ type: 'JOB_PROGRESS', job: {
id: 'component-complete', title: 'FFmpeg', kind: 'dependency', stage: 'complete', status: 'complete', percent: 100, result: { title: 'FFmpeg' }
} }));
assert.equal(await page.locator('#ytdl-notifications [data-id="component-complete"] .action').isVisible(), false);
const components = await context.newPage();
await components.addInitScript(() => { window.__dependencyMissing = true; });
await components.goto('https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw');
if (!await components.locator('#helper-panel').evaluate(panel => panel.open)) await components.locator('#helper-panel > summary').click();
await components.locator('.dependencies summary').click();
await components.locator('.dependency-row[data-dependency="ffmpeg"][data-status="missing"]').waitFor();
assert.equal(await components.locator('#download-form').isVisible(), false);
assert.equal(await components.locator('#engine option[value="nvenc"]').isDisabled(), true);
assert.equal(await components.locator('.dependency-row button').count(), 0);
assert.equal(await components.locator('#helper-install').textContent(), 'Восстановить');
assert.equal(await components.locator('#helper-install').getAttribute('href'), 'https://git.lonarpgproxy.space/OwnerLink/YouTubeDL/releases/latest');
assert.equal(await components.locator('#helper-install').getAttribute('target'), '_blank');
assert.equal(await components.locator('#helper-install').getAttribute('download'), null);
// A repaired bundle reports new capabilities after the helper restarts.
await components.evaluate(() => { window.__dependencyMissing = false; });
await components.locator('#helper-start').click();
await components.locator('#download-form').waitFor({state:'visible'});
assert.equal(await components.locator('.dependency-row[data-status="installed"]').count(), 5);
assert.equal(await components.locator('#engine option[value="nvenc"]').isDisabled(), false);
await components.close();
const amdPage=await context.newPage();
await amdPage.addInitScript(() => { window.__amdOnly=true; });
await amdPage.goto('https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw');
await amdPage.locator('#download-form').waitFor({state:'visible'});
assert.equal(await amdPage.locator('#engine option[value="nvenc"]').isDisabled(),true);
assert.equal(await amdPage.locator('#engine option[value="amf"]').isDisabled(),false);
await amdPage.locator('.advanced summary').click();
await amdPage.locator('#codec').selectOption('hevc'); await amdPage.locator('#engine').selectOption('amf');
assert.match(await amdPage.locator('#processing-hint').textContent(),/AMD AMF/);
await amdPage.locator('#codec').selectOption('vp9');
assert.match(await amdPage.locator('#processing-hint').textContent(),/недоступен/);
assert.equal(await amdPage.locator('#download').isDisabled(),true);
await amdPage.locator('#codec').selectOption('hevc');
assert.equal(await amdPage.locator('#download').isDisabled(),false);
await amdPage.locator('#helper-panel > summary').click();
assert.equal(await amdPage.locator('#helper-install').textContent(),'Переустановить');
await amdPage.locator('#auto-update').uncheck();
await amdPage.locator('#helper-update').click();
await amdPage.locator('#active-jobs .job-stage').filter({hasText:'Скачиваю FFmpeg'}).waitFor();
assert.equal(await amdPage.locator('#helper-update').isDisabled(),true);
assert.equal(await amdPage.evaluate(() => window.__updateRequests.length),1);
await amdPage.evaluate(() => window.__emitJob({id:'fixture-update',kind:'update',canCancel:false,status:'complete',stage:'complete',result:{title:'Все компоненты актуальны'}}));
await amdPage.locator('#helper-update:not([disabled])').waitFor();
assert.equal(await amdPage.locator('#active-jobs-section').isVisible(),false,'Completed component updates leave the active list');
assert.match(await amdPage.locator('#update-note').textContent(),/актуальны/);
await amdPage.close();
const loginPage = await context.newPage();
await loginPage.addInitScript(() => { window.__requiresLogin = true; window.__denySessionPermission = true; });
await loginPage.goto('https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw&theme=dark');
await loginPage.locator('#alert:not([hidden])').waitFor();
assert.equal(await loginPage.locator('#helper-status').textContent(), 'Помощник работает');
assert.equal(await loginPage.locator('#video-title').textContent(), 'Не удалось получить качество');
assert.match(await loginPage.locator('#alert').textContent(), /Использовать вход в YouTube/);
assert.ok(!(await loginPage.locator('#alert').textContent()).includes('cookies-from-browser'));
await loginPage.locator('#session-enable').click();
assert.match(await loginPage.locator('#alert').textContent(), /не разрешён/);
await loginPage.evaluate(() => { window.__denySessionPermission = false; });
await loginPage.locator('#session-enable').click();
await loginPage.locator('#download-form').waitFor({state:'visible'});
assert.equal(await loginPage.locator('#session-status').textContent(), 'Вход браузера подключён');
assert.equal(await loginPage.locator('#alert').isVisible(), false);
await loginPage.screenshot({path:path.join(artifacts,'session-connected.png'),animations:'disabled'});
await loginPage.locator('#session-disable').click();
await loginPage.locator('#session-panel[data-state="disabled"]').waitFor();
assert.equal(await loginPage.locator('#session-status').textContent(), 'Вход в YouTube не подключён');
await loginPage.close();
const storagePage = await context.newPage();
await storagePage.goto('https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DjNQXAC9IVRw&theme=dark');
await storagePage.locator('#download-form').waitFor({state:'visible'});
await storagePage.locator('#folder').fill('C:\\Finished');
await storagePage.locator('#pick-work-folder').click();
await storagePage.waitForFunction(() => window.__fixtureStorage.preferences.workFolder === 'D:\\Videos\\YouTube');
assert.equal(await storagePage.locator('#folder').inputValue(), 'C:\\Finished');
await storagePage.locator('#download').click();
await storagePage.waitForFunction(() => window.__downloadRequests.length === 1);
const storageRequest = await storagePage.evaluate(() => window.__downloadRequests[0]);
assert.equal(storageRequest.folder, 'C:\\Finished');
assert.equal(storageRequest.workFolder, 'D:\\Videos\\YouTube');
await storagePage.close();
assert.deepEqual(errors, []);
console.log("UI checks passed: direct download + settings, stationary buttons, thumbnails, Shorts, real-state indicators, reconnect, themes and narrow layout.");
} finally { await browser.close(); }
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const browser = await chromium.launch({ headless: true, executablePath: "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
const context = await browser.newContext({ viewport: { width: 360, height: 900 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await context.addInitScript(() => { window.__helperVersion = "1.3.0"; });
await context.route("https://ui.ytdl.test/**", async route => {
const name = new URL(route.request().url()).pathname.slice(1);
if (!["downloader.html", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "styles.css", "shared.js", "session.js"].includes(name)) return route.abort();
return route.fulfill({ body: await fs.readFile(path.join(root, "extension", name)), contentType: name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript" });
});
try {
const page = await context.newPage();
const errors = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto("https://ui.ytdl.test/downloader.html?theme=dark&url=https://www.youtube.com/watch?v=aircAruvnKk");
await page.locator('#helper-panel[data-state="incompatible"]').waitFor();
assert.match(await page.locator('#helper-message').textContent(), /1\.3\.0.*1\.4\.5/);
assert.match(await page.locator('#helper-versions').textContent(), /Расширение 1\.4\.5.*Помощник 1\.3\.0/);
assert.equal(await page.locator('#helper-install').textContent(), 'Обновить помощник');
assert.equal(await page.locator('#download').isDisabled(), true);
assert.equal(await page.locator('#download-form').isVisible(), false);
await page.locator('#open-setup').click();
await page.locator('#setup-next').click();
await page.locator('#setup-helper[data-state="incompatible"]').waitFor();
assert.equal(await page.locator('#setup-next').isDisabled(), true);
assert.equal(await page.locator('#setup-install').textContent(), 'Обновить помощник');
assert.match(await page.locator('#setup-versions').textContent(), /1\.4\.5.*1\.3\.0/);
await page.evaluate(() => { window.__helperVersion = '1.3.2'; });
await page.locator('#setup-check').click();
await page.locator('#setup-helper[data-state="running"]').waitFor();
assert.match(await page.locator('#setup-versions').textContent(), /Версии совместимы/);
await page.locator('#setup-next:not([disabled])').waitFor();
await page.evaluate(() => { window.__helperVersion = '1.3.3'; });
await page.locator('#setup-check').click();
await page.locator('#setup-helper[data-state="incompatible"]').waitFor();
assert.equal(await page.locator('#setup-next').isDisabled(), true);
assert.equal(await page.locator('#setup-install').textContent(), 'Обновить расширение');
assert.equal(await page.locator('#setup-install-steps').isVisible(), false);
await page.locator('#setup-close').click();
assert.equal(await page.locator('#helper-install').textContent(), 'Обновить расширение');
assert.match(await page.locator('#helper-message').textContent(), /Помощник 1\.3\.3.*расширением 1\.4\.5/);
assert.equal(await page.locator('#download').isDisabled(), true);
await page.evaluate(() => { window.__helperVersion = '1.3.2'; });
await page.locator('#helper-start').click();
await page.locator('#helper-panel[data-state="running"]').waitFor();
await page.locator('#download:not([disabled])').waitFor();
assert.match(await page.locator('#helper-versions').textContent(), /Версии совместимы/);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await fs.mkdir(path.join(root, 'test-artifacts'), { recursive: true });
await page.screenshot({ path: path.join(root, 'test-artifacts/version-compatibility-360.png'), animations: 'disabled' });
assert.deepEqual(errors, []);
console.log('Version UI passed in Edge: both update directions, versions shown in settings and first-run wizard, downloads blocked until compatible, recovery without reload, 360px layout.');
} finally {
await context.close();
await browser.close();
}
+50
View File
@@ -0,0 +1,50 @@
"""Build the small extension archive; the Windows helper is distributed separately."""
import binascii
import json
import struct
import zlib
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def png_chunk(kind, data):
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", binascii.crc32(kind + data) & 0xffffffff)
def icon(size):
rows = bytearray()
for y in range(size):
rows.append(0)
for x in range(size):
u, v = (x + 0.5) / size, (y + 0.5) / size
dx, dy = max(0.18 - u, 0, u - 0.82), max(0.18 - v, 0, v - 0.82)
alpha = 255 if dx * dx + dy * dy < 0.18 ** 2 else 0
color = (int(74 - 47 * v), int(205 - 52 * v), int(243 - 32 * v), alpha)
stem = 0.44 < u < 0.56 and 0.22 < v < 0.60
arrow = 0.46 < v < 0.71 and abs(u - 0.5) < (0.71 - v) * 1.15
tray = (0.26 < u < 0.74 and 0.76 < v < 0.82) or (0.26 < u < 0.32 or 0.68 < u < 0.74) and 0.66 < v < 0.82
if stem or arrow or tray: color = (8, 39, 57, alpha)
rows.extend(color)
return b"\x89PNG\r\n\x1a\n" + png_chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) + png_chunk(b"IDAT", zlib.compress(bytes(rows))) + png_chunk(b"IEND", b"")
def main():
icons = ROOT / "extension" / "icons"
icons.mkdir(exist_ok=True)
for size in (16, 32, 48, 128): (icons / f"{size}.png").write_bytes(icon(size))
(ROOT / "dist").mkdir(exist_ok=True)
version = json.loads((ROOT / "extension" / "manifest.json").read_text(encoding="utf-8"))["version"]
destination = ROOT / "dist" / f"YouTubeDL-{version}.zip"
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in (ROOT / "extension").rglob("*"):
if path.is_file() and path.suffix.lower() != ".exe" and "__pycache__" not in path.parts:
archive.write(path, str(path.relative_to(ROOT)))
for name in ("INSTALL.md", "LICENSE", "PRIVACY.md", "THIRD_PARTY.md"):
path = ROOT / name
if path.is_file(): archive.write(path, name)
print(destination)
if __name__ == "__main__": main()
+172
View File
@@ -0,0 +1,172 @@
"""Build the offline Windows x64 installer with only the system C# compiler.
Build-time downloads are cached and recorded. The resulting installer has no
network code, pip, or dependency on tools installed on the recipient's machine.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import hashlib
import json
import os
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / ".build-cache"
BUILD = ROOT / ".build"
KIT = BUILD / "kit"
PYTHON = "3.13.14"
PYTHON_SHA256 = "90b4e5b9898b72d744650524bff92377c367f44bd5fbd09e3148656c080ad907"
NODE = "v24.21.0"
FFMPEG_ARCHIVE = "ffmpeg-master-latest-win64-gpl-shared.zip"
FFMPEG_SHA256 = "eb7afd4ecced72ab7d5d6fdd36dbc359173aa2daf6081817f7b678345898838b"
def digest(path):
value = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
def fetch(url, name, expected=None):
destination = CACHE / name
if not destination.is_file():
print(f"Downloading {name}", flush=True)
partial = destination.with_suffix(destination.suffix + ".partial")
with requests.get(url, stream=True, timeout=90) as source, partial.open("wb") as output:
source.raise_for_status()
for chunk in source.iter_content(1024 * 1024): output.write(chunk)
partial.replace(destination)
actual = digest(destination)
if expected and actual != expected:
raise ValueError(f"Checksum mismatch: {name}")
return {"url": url, "file": name, "sha256": actual}
def safe_extract(archive, destination):
root = destination.resolve()
with zipfile.ZipFile(archive) as package:
for item in package.infolist():
candidate = (root / item.filename).resolve()
if root not in candidate.parents and candidate != root:
raise ValueError("Unsafe archive path")
package.extract(item, root)
def prepare():
CACHE.mkdir(exist_ok=True); BUILD.mkdir(exist_ok=True)
if KIT.exists():
if KIT.resolve().parent != BUILD.resolve() or KIT.is_symlink():
raise ValueError("Unsafe build cleanup path")
shutil.rmtree(KIT)
KIT.mkdir()
sources = [
(f"https://www.python.org/ftp/python/{PYTHON}/python-{PYTHON}-embed-amd64.zip", "python.zip", PYTHON_SHA256),
(f"https://nodejs.org/dist/{NODE}/node-{NODE}-win-x64.zip", "node.zip", None),
(f"https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/{FFMPEG_ARCHIVE}", FFMPEG_ARCHIVE, FFMPEG_SHA256),
]
# Node publishes a SHA256 manifest for every official release.
response = requests.get(f"https://nodejs.org/dist/{NODE}/SHASUMS256.txt", timeout=30); response.raise_for_status()
checksums = response.text
node_hash = next(line.split()[0] for line in checksums.splitlines() if line.endswith(f"node-{NODE}-win-x64.zip"))
sources[1] = (*sources[1][:2], node_hash)
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
records = list(executor.map(lambda item: fetch(*item), sources))
python = KIT / "runtime" / "python"
python.mkdir(parents=True)
safe_extract(CACHE / "python.zip", python)
(python / "python313._pth").write_text("python313.zip\n.\nLib/site-packages\n../../\nimport site\n", encoding="ascii")
# Verify the signed Python executable after extracting the official ZIP.
verifier = os.environ.get("YTDL_BUILD_POWERSHELL") or "powershell.exe"
signature = subprocess.check_output([verifier, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
"$taskSignature=Get-AuthenticodeSignature -LiteralPath $env:YTDL_VERIFY_PYTHON; Write-Output $taskSignature.Status"],
env={**os.environ, "YTDL_VERIFY_PYTHON": str(python / "python.exe")}, text=True).strip()
if signature != "Valid":
raise ValueError("Embedded Python signature verification failed: " + signature)
binary = KIT / "runtime" / "bin"; binary.mkdir()
licenses = KIT / "licenses"; licenses.mkdir()
with zipfile.ZipFile(CACHE / "node.zip") as package:
for name in ("node.exe", "LICENSE"):
item = next(entry for entry in package.namelist() if entry.endswith("/" + name))
(binary / name if name.endswith(".exe") else licenses / "Node-LICENSE.txt").write_bytes(package.read(item))
with zipfile.ZipFile(CACHE / FFMPEG_ARCHIVE) as package:
for item in package.namelist():
name = Path(item).name
if "/bin/" in item and (name.endswith(".dll") or name in {"ffmpeg.exe", "ffprobe.exe"}):
(binary / name).write_bytes(package.read(item))
elif name == "LICENSE.txt":
(licenses / "FFmpeg-LICENSE.txt").write_bytes(package.read(item))
wheels = CACHE / "wheels"; wheels.mkdir(exist_ok=True)
options = ["--platform", "win_amd64", "--python-version", "3.13", "--implementation", "cp",
"--abi", "cp313", "--abi", "abi3", "--abi", "none", "--only-binary", ":all:"]
requirement = "yt-dlp[default]==2026.8.19"
subprocess.run([sys.executable, "-m", "pip", "download", "--disable-pip-version-check", "-d", str(wheels), *options, requirement], check=True)
subprocess.run([sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "--no-index", "--find-links", str(wheels),
"--target", str(python / "Lib" / "site-packages"), "--no-compile", "--ignore-requires-python", *options, requirement], check=True)
(licenses / "THIRD-PARTY.txt").write_text(
"Python: https://www.python.org/ ; license: runtime/python/LICENSE.txt\n"
"Node.js: https://nodejs.org/ ; license: Node-LICENSE.txt\n"
"FFmpeg Windows build and corresponding source/build instructions: https://github.com/yt-dlp/FFmpeg-Builds ; GPL license: FFmpeg-LICENSE.txt\n"
"yt-dlp and Python libraries: runtime/python/Lib/site-packages/*.dist-info (licenses and metadata)\n"
"yt-dlp source: https://github.com/yt-dlp/yt-dlp/tree/2026.08.19\n", encoding="utf-8")
(BUILD / "sources.json").write_text(json.dumps(records, indent=2), encoding="utf-8")
def build():
if not KIT.is_dir():
raise ValueError("Run with --prepare first.")
scripts = KIT / "runtime/python/Lib/site-packages/bin"
if scripts.exists():
if KIT.resolve() not in scripts.resolve().parents or scripts.is_symlink():
raise ValueError("Unsafe console-script cleanup path")
# pip's generated EXE launchers refer to the build interpreter. The helper
# imports these libraries directly, so no console launchers are shipped.
shutil.rmtree(scripts)
shutil.copytree(ROOT / "host", KIT / "host", dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
sys.path.insert(0, str(ROOT))
from host.common import VERSION
version = VERSION
metadata = {"product": "YouTubeDL.Helper", "mode": "autonomous", "version": version, "platform": "windows-x64",
"tools": {"python": "runtime/python/python.exe", "node": "runtime/bin/node.exe", "ffmpeg": "runtime/bin/ffmpeg.exe", "ffprobe": "runtime/bin/ffprobe.exe"},
"sources": json.loads((BUILD / "sources.json").read_text(encoding="utf-8"))}
(KIT / "bundle.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
environment = {**os.environ, "PATH": str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"),
"PYTHONPATH": "C:/NONEXISTENT", "PYTHONHOME": "C:/NONEXISTENT", "PYTHONNOUSERSITE": "1"}
subprocess.run([str(KIT / "runtime/python/python.exe"), "-I", "-B", "-m", "host.bootstrap", "verify"], env=environment, check=True)
files = {str(path.relative_to(KIT)).replace("\\", "/"): digest(path) for path in KIT.rglob("*") if path.is_file() and path.name != "bundle-files.json"}
(KIT / "bundle-files.json").write_text(json.dumps(files, indent=2), encoding="utf-8")
archive = BUILD / "bundle.zip"
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as package:
for file in KIT.rglob("*"):
if file.is_file(): package.write(file, str(file.relative_to(KIT)))
code = (ROOT / "tools" / "setup.cs").read_text(encoding="utf-8").replace("@BUNDLE_VERSION@", version).replace("@BUNDLE_HASH@", digest(archive))
source = BUILD / "setup.cs"; source.write_text(code, encoding="utf-8-sig")
compiler = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Microsoft.NET/Framework64/v4.0.30319/csc.exe"
common = [str(compiler), "/nologo", "/target:winexe", "/platform:x64", "/optimize+", "/r:System.Windows.Forms.dll",
"/r:System.Drawing.dll", "/r:System.IO.Compression.dll", "/r:System.IO.Compression.FileSystem.dll", "/r:System.Web.Extensions.dll",
f"/win32manifest:{ROOT / 'tools/setup.manifest'}"]
manager = BUILD / "YouTubeDL-Manager.exe"
subprocess.run([*common, f"/out:{manager}", str(source)], check=True)
(ROOT / "dist").mkdir(exist_ok=True)
installer = ROOT / "dist/YouTubeDL-Helper-Setup.exe"
subprocess.run([*common, "/define:SETUP", f"/resource:{archive},bundle.zip", f"/resource:{manager},manager.exe", f"/out:{installer}", str(source)], check=True)
destination = ROOT / "dist" / f"YouTubeDL-Helper-{version}-Setup.exe"
shutil.copy2(installer, destination)
(ROOT / "dist" / f"YouTubeDL-Helper-{version}-manifest.json").write_text(json.dumps({**metadata, "installerSHA256": digest(installer),
"installerBytes": installer.stat().st_size, "bundleSHA256": digest(archive)}, indent=2), encoding="utf-8")
print(f"Offline installer built: {destination} ({installer.stat().st_size / 1024**2:.1f} MiB)", flush=True)
if __name__ == "__main__":
parser = argparse.ArgumentParser(); parser.add_argument("--prepare", action="store_true"); parser.add_argument("--prepare-only", action="store_true")
args = parser.parse_args()
if args.prepare or args.prepare_only: prepare()
if not args.prepare_only: build()
+27
View File
@@ -0,0 +1,27 @@
// Capture the real extension renderer with the existing deterministic fixture.
// This never opens a personal browser profile or accesses real cookies.
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const browser = await chromium.launch({ headless: true, executablePath: process.env.YTDL_TEST_BROWSER || "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" });
try {
const context = await browser.newContext({ viewport: { width: 520, height: 1080 }, reducedMotion: "reduce" });
await context.addInitScript({ path: path.join(root, "tests/ui-fixture.js") });
await context.route("**/*", async route => {
const url = new URL(route.request().url());
const name = path.basename(url.pathname);
if (url.hostname !== "ui.ytdl.test" || !["downloader.html", "downloader.js", "onboarding.js", "ui-connection.js", "theme-init.js", "styles.css", "shared.js", "session.js"].includes(name)) return route.abort();
return route.fulfill({ body: await fs.readFile(path.join(root,"extension",name)), contentType: name.endsWith(".html") ? "text/html" : name.endsWith(".css") ? "text/css" : "application/javascript" });
});
const page = await context.newPage();
await fs.mkdir(path.join(root,"site/assets"),{recursive:true});
for (const theme of ["dark","light"]) {
await page.goto(`https://ui.ytdl.test/downloader.html?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DaircAruvnKk&theme=${theme}`);
await page.locator("#download-form").waitFor({state:"visible"});
await page.locator("#quality").selectOption("137");
await page.locator("#folder").fill("C:\\Videos");
await page.locator(".app").screenshot({path:path.join(root,`site/assets/menu-${theme}.png`),animations:"disabled"});
}
} finally { await browser.close(); }
+86
View File
@@ -0,0 +1,86 @@
"""Publish license texts and pinned upstream source materials alongside the helper.
Run after tools/build_bundle.py. This does not alter or install the helper.
Third-party FFmpeg dependency download/build recipes are preserved verbatim.
"""
from __future__ import annotations
import concurrent.futures
import hashlib
import json
import subprocess
import zipfile
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parent.parent
KIT = ROOT / ".build/kit"
CACHE = ROOT / ".build-cache/release-sources"
FFMPEG_REVISION = "17ec99894249a68444d145ea8870713a4fb7f342"
BUILD_REVISION = "8fc3fe09d1d84a10cecc2b08706e303e27e64834"
def download(item):
CACHE.mkdir(parents=True, exist_ok=True)
target = CACHE / item["file"]
if not target.is_file():
response = requests.get(item["url"], timeout=(20, 180))
response.raise_for_status()
target.write_bytes(response.content)
data = target.read_bytes()
checksum = hashlib.sha256(data).hexdigest()
if item.get("expectedSHA256") and item["expectedSHA256"] != checksum:
raise ValueError(f"Source hash mismatch: {item['file']}")
print(f"Source: {item['file']} ({len(data)} bytes)", flush=True)
return {**item, "sha256": checksum, "bytes": len(data)}
def main():
manifest = json.loads((ROOT / "dist/YouTubeDL-Helper-manifest.json").read_text(encoding="utf-8"))
sources = [
{"file": f"FFmpeg-{FFMPEG_REVISION}.tar.gz", "url": f"https://codeload.github.com/FFmpeg/FFmpeg/tar.gz/{FFMPEG_REVISION}", "component": "FFmpeg"},
{"file": f"FFmpeg-Builds-{BUILD_REVISION}.tar.gz", "url": f"https://codeload.github.com/yt-dlp/FFmpeg-Builds/tar.gz/{BUILD_REVISION}", "component": "FFmpeg build and dependency source recipes"},
]
for metadata in sorted((KIT / "runtime/python/Lib/site-packages").glob("*.dist-info/METADATA")):
fields = dict(line.split(": ", 1) for line in metadata.read_text(encoding="utf-8").splitlines() if line.startswith(("Name: ", "Version: ")))
name, version = fields["Name"], fields["Version"]
response = requests.get(f"https://pypi.org/pypi/{name}/{version}/json", timeout=30)
response.raise_for_status()
sdist = next((file for file in response.json()["urls"] if file["packagetype"] == "sdist"), None)
if sdist:
sources.append({"file": sdist["filename"], "url": sdist["url"], "expectedSHA256": sdist["digests"]["sha256"], "component": f"{name} {version}"})
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
records = list(pool.map(download, sources))
configuration = subprocess.run([str(KIT / "runtime/bin/ffmpeg.exe"), "-version"], capture_output=True, text=True, check=True).stdout
destination = ROOT / "dist/YouTubeDL-ThirdParty-Materials.zip"
with zipfile.ZipFile(destination,"w",zipfile.ZIP_DEFLATED) as archive:
for name in ("THIRD_PARTY.md", "LICENSE", "CONTRIBUTING.md"):
archive.write(ROOT / name, name)
archive.writestr("helper-manifest.json", json.dumps(manifest,indent=2))
archive.writestr("source-index.json", json.dumps(records,indent=2))
archive.writestr("FFmpeg-configuration.txt", configuration)
archive.writestr("SOURCE-README.txt", f"""YouTubeDL helper {manifest['version']}: upstream source materials and licenses.
The exact FFmpeg source revision is in sources/; FFmpeg-Builds scripts contain
the pinned download and build recipes for its third-party libraries.
Extract the FFmpeg-Builds source archive and follow its Docker build instructions.
Run ./download.sh win64 gpl-shared to fetch dependency sources; build.sh is the
corresponding build recipe. Read scripts.d, variants and util for configuration.
The supplied FFmpeg binary is unchanged from yt-dlp/FFmpeg-Builds.
Python and Node.js official sources:
https://www.python.org/ftp/python/3.13.14/Python-3.13.14.tgz
https://nodejs.org/dist/v24.21.0/node-v24.21.0.tar.xz
Python package source distributions are included where PyPI provides them.
The original installer source and Native Messaging helper source are in the
YouTubeDL repository. The root LICENSE governs original YouTubeDL materials.
Third-party components are distributed under their own licenses; see THIRD_PARTY.md.
""")
for record in records:
archive.write(CACHE / record["file"], "sources/" + record["file"])
for file in KIT.rglob("*"):
if not file.is_file(): continue
relative = file.relative_to(KIT)
if "licenses" in relative.parts or (".dist-info" in str(relative) and (file.name == "METADATA" or any(word in str(relative).lower() for word in ("license","copying","notice")))) or file.name == "LICENSE.txt":
archive.write(file,"licenses/" + str(relative).replace("\\","/"))
print(f"Materials: {destination.name} ({destination.stat().st_size} bytes)", flush=True)
if __name__ == "__main__": main()
+16
View File
@@ -0,0 +1,16 @@
param([Parameter(Mandatory=$true)][string]$InstallerPath)
$ErrorActionPreference = 'Stop'
$taskInstaller = (Resolve-Path -LiteralPath $InstallerPath).Path
$taskPreviousFlag = $env:YTDL_INSTALL_NONINTERACTIVE
try {
if ($taskInstaller.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) {
$taskProcess = Start-Process -FilePath $taskInstaller -ArgumentList '--silent' -WindowStyle Hidden -Wait -PassThru
if ($taskProcess.ExitCode -ne 0) { throw "Offline installer failed: $($taskProcess.ExitCode)" }
return
}
$env:YTDL_INSTALL_NONINTERACTIVE = '1'
& $taskInstaller
if ($LASTEXITCODE -ne 0) { throw "Helper installer failed: $LASTEXITCODE" }
} finally {
$env:YTDL_INSTALL_NONINTERACTIVE = $taskPreviousFlag
}
+412
View File
@@ -0,0 +1,412 @@
// Offline installer and ownership-scoped uninstaller. .NET Framework is part of Windows.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows.Forms;
using Microsoft.Win32;
internal static class Program
{
const string Version = "@BUNDLE_VERSION@";
const string BundleHash = "@BUNDLE_HASH@";
const string Product = "YouTubeDL.Helper";
const string Host = "com.youtubedl.native";
const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\YouTubeDL.Helper";
static readonly string[] Browsers = { @"Google\Chrome", @"Microsoft\Edge", "Chromium" };
static readonly JavaScriptSerializer Json = new JavaScriptSerializer { MaxJsonLength = 16 * 1024 * 1024 };
[STAThread]
public static int Main(string[] args)
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true });
Console.SetError(new StreamWriter(Console.OpenStandardError(), new UTF8Encoding(false)) { AutoFlush = true });
AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false);
AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false);
if (args.Contains("--pick-folder"))
{
Application.EnableVisualStyles();
using (var dialog = new FolderBrowserDialog())
using (var owner = new Form { TopMost = true, ShowInTaskbar = false, Opacity = 0 })
{
string initial = Value(args, "--initial-folder");
if (initial != null) dialog.SelectedPath = Json.Deserialize<string>(Encoding.UTF8.GetString(Convert.FromBase64String(initial)));
dialog.Description = "YouTubeDL — папка сохранения"; dialog.ShowNewFolderButton = true;
owner.Show();
Console.WriteLine(Json.Serialize(dialog.ShowDialog(owner) == DialogResult.OK ? dialog.SelectedPath : null));
return 0;
}
}
bool silent = args.Contains("--silent");
bool uninstall = args.Contains("--uninstall");
string explicitRoot = Value(args, "--target-root");
string appRoot = Path.GetFullPath(explicitRoot ?? (uninstall ? Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "YouTubeDL", "Helper"))).TrimEnd(Path.DirectorySeparatorChar);
Action<Action<int, string>> work = report => Run(appRoot, args, uninstall, report);
if (silent)
{
string previous = null;
try { work((percent, message) => { if (message != previous) Console.WriteLine(message); previous = message; }); return 0; }
catch (Exception error) { Console.Error.WriteLine(error.ToString()); return 1; }
}
Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false);
var window = new SetupWindow(work, uninstall);
Application.Run(window);
return window.ExitCode;
}
static string Value(string[] args, string key)
{
int index = Array.IndexOf(args, key);
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
}
static void Run(string root, string[] args, bool uninstall, Action<int, string> report)
{
if (Path.GetPathRoot(root).TrimEnd('\\') == root.TrimEnd('\\') || root == Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
throw new Exception("Некорректная папка установки.");
if (Directory.Exists(root) && (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0)
throw new Exception("Папка установки не должна быть символической ссылкой.");
string waiting = Value(args, "--wait-pid");
if (waiting != null)
{
try { using (var parent = Process.GetProcessById(Int32.Parse(waiting))) { if (!parent.WaitForExit(30000)) throw new Exception("Помощник ещё работает. Закройте меню и повторите удаление."); } }
catch (ArgumentException) { }
}
if (!uninstall && !args.Contains("--finish-uninstall"))
{
Directory.CreateDirectory(root);
if (!args.Contains("--activate-release"))
{
// AppData virtualization can merge an existing real directory
// with newly created children elsewhere. Resolve a fresh child
// to find the directory where this install will actually write.
string probeName = ".ytdl-location-" + Guid.NewGuid().ToString("N");
string probe = Inside(root, probeName);
Directory.CreateDirectory(probe);
string physicalProbe = PhysicalDirectory(probe);
if (!String.Equals(Path.GetFileName(physicalProbe), probeName, StringComparison.OrdinalIgnoreCase))
throw new Exception("Не удалось определить папку установки.");
root = Path.GetDirectoryName(physicalProbe);
Directory.Delete(physicalProbe, false);
}
}
// Keep the manager, marker and runtime in the same physical directory.
root = PhysicalDirectory(root).TrimEnd(Path.DirectorySeparatorChar);
string key;
using (var sha = SHA256.Create()) key = BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(PhysicalDirectory(root).ToLowerInvariant()))).Replace("-", "").Substring(0, 16);
using (var mutex = new Mutex(false, "Local\\YouTubeDL.Setup." + key))
{
if (!mutex.WaitOne(0)) throw new Exception("Другая установка или удаление уже выполняется.");
try
{
if (args.Contains("--activate-release")) Activate(root, args, report);
else if (args.Contains("--finish-uninstall")) FinishRemoval(root, report);
else if (uninstall) Uninstall(root, report);
else
{
#if SETUP
Install(root, !args.Contains("--no-register"), report);
#else
throw new Exception("Для установки запустите YouTubeDL-Helper-Setup.exe.");
#endif
}
}
finally { mutex.ReleaseMutex(); }
}
}
static string Inside(string root, string relative)
{
if (String.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative)) throw new Exception("Некорректный путь в комплекте.");
string full = Path.GetFullPath(Path.Combine(root, relative));
if (!full.StartsWith(root.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase)) throw new Exception("Путь выходит за папку комплекта.");
for (var parent = new DirectoryInfo(Path.GetDirectoryName(full)); parent != null && parent.FullName.Length >= root.Length; parent = parent.Parent)
if (parent.Exists && (parent.Attributes & FileAttributes.ReparsePoint) != 0) throw new Exception("Символические ссылки в папке комплекта не поддерживаются.");
if (File.Exists(full) && (File.GetAttributes(full) & FileAttributes.ReparsePoint) != 0) throw new Exception("Файл комплекта заменён символической ссылкой.");
return full;
}
static string Hash(Stream stream)
{ using (var sha = SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); }
static Dictionary<string, object> ObjectFile(string path)
{ return Json.Deserialize<Dictionary<string, object>>(File.ReadAllText(path, Encoding.UTF8)); }
#if SETUP
static void Install(string root, bool register, Action<int, string> report)
{
report(2, "Проверяю автономный комплект…");
string releases = Path.Combine(root, "releases"); Directory.CreateDirectory(releases);
string stage = Inside(releases, Version + "-" + Guid.NewGuid().ToString("N").Substring(0, 8)); Directory.CreateDirectory(stage);
string archive = Inside(stage, "payload.zip");
string release = null;
bool installed = false;
try
{
using (var input = Assembly.GetExecutingAssembly().GetManifestResourceStream("bundle.zip"))
using (var output = File.Create(archive)) input.CopyTo(output);
using (var input = File.OpenRead(archive)) if (Hash(input) != BundleHash) throw new Exception("Контрольная сумма комплекта не совпала.");
using (var zip = ZipFile.OpenRead(archive))
{
long total = zip.Entries.Sum(entry => entry.Length), written = 0;
if (total > 1024L * 1024 * 1024) throw new Exception("Некорректный размер комплекта.");
foreach (var entry in zip.Entries)
{
if (entry.FullName.EndsWith("/")) continue;
if (((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000) throw new Exception("Символическая ссылка в архиве.");
string destination = Inside(stage, entry.FullName);
Directory.CreateDirectory(Path.GetDirectoryName(destination));
using (var input = entry.Open()) using (var output = File.Create(destination)) input.CopyTo(output);
written += entry.Length;
report(5 + (int)(written * 70 / total), "Распаковываю компоненты…");
}
}
File.Delete(archive);
report(78, "Проверяю Python, загрузчик, JavaScript и FFmpeg…");
// Virus scanners can briefly hold a newly extracted EXE or DLL.
for (int attempt = 0; ; attempt++)
{
try { Python(stage, "verify"); break; }
catch { if (attempt == 2) throw; Thread.Sleep(700 * (attempt + 1)); }
}
release = stage;
Python(release, "configure");
string manager = Inside(root, "YouTubeDL-Manager.exe");
using (var input = Assembly.GetExecutingAssembly().GetManifestResourceStream("manager.exe"))
using (var output = File.Create(manager)) input.CopyTo(output);
string marker = Inside(root, "installation.json");
string history = Inside(root, @"data\completed-jobs.json");
string previousHistory = Inside(root, @".runtime\completed-jobs.json");
if (!File.Exists(history) && File.Exists(previousHistory))
{ Directory.CreateDirectory(Path.GetDirectoryName(history)); File.Copy(previousHistory, history); }
File.WriteAllText(marker, Json.Serialize(new { product = Product, version = Version, current = release, registered = register }), Encoding.UTF8);
string updates = Inside(root, @"data\update-settings.json");
if (File.Exists(updates))
{ var options = ObjectFile(updates); options["lastCheck"] = 0; File.WriteAllText(updates, Json.Serialize(options), new UTF8Encoding(false)); }
if (register)
{
report(92, "Подключаю помощник к Edge и Chrome…");
foreach (string browser in Browsers)
using (var registry = Registry.CurrentUser.CreateSubKey(@"Software\" + browser + @"\NativeMessagingHosts\" + Host))
registry.SetValue("", Path.Combine(release, @".runtime\native-host.json"));
using (var registry = Registry.CurrentUser.CreateSubKey(UninstallKey))
{
registry.SetValue("DisplayName", "YouTubeDL Helper"); registry.SetValue("DisplayVersion", Version);
registry.SetValue("Publisher", "YouTubeDL"); registry.SetValue("InstallLocation", root);
registry.SetValue("UninstallString", "\"" + manager + "\" --uninstall");
registry.SetValue("QuietUninstallString", "\"" + manager + "\" --uninstall --silent");
registry.SetValue("NoModify", 1); registry.SetValue("NoRepair", 1);
}
}
installed = true;
report(100, "Готово. Вернитесь в YouTubeDL и нажмите «Запустить».");
}
finally
{
// Only this freshly generated, unpublished folder may be removed.
// Cleanup must not hide the original validation/installation error.
try
{
if (!installed && Directory.Exists(stage) && (File.GetAttributes(stage) & FileAttributes.ReparsePoint) == 0)
{
var candidates = Directory.EnumerateFileSystemEntries(stage, "*", SearchOption.AllDirectories).ToArray();
if (candidates.All(path => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == 0)) Directory.Delete(stage, true);
}
}
catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}
#endif
static void Activate(string root, string[] args, Action<int, string> report)
{
string marker = Inside(root, "installation.json");
var previous = ObjectFile(marker);
if ((string)previous["product"] != Product) throw new Exception("Установленный комплект не найден.");
string expected = PhysicalDirectory(Value(args, "--expected-current"));
string current = PhysicalDirectory((string)previous["current"]);
if (!String.Equals(current, expected, StringComparison.OrdinalIgnoreCase)) throw new Exception("Помощник уже изменён другой установкой. Повторите проверку обновлений.");
string stage = PhysicalDirectory(Path.GetFullPath(Value(args, "--activate-release")));
if (!String.Equals(Path.GetDirectoryName(stage), PhysicalDirectory(Inside(root, "releases")), StringComparison.OrdinalIgnoreCase)) throw new Exception("Папка обновления вне комплекта.");
var metadata = ObjectFile(Inside(stage, "bundle.json"));
if ((string)metadata["product"] != Product || (string)metadata["mode"] != "autonomous") throw new Exception("Некорректный комплект обновления.");
report(10, "Проверяю контрольные суммы обновления…");
foreach (var entry in ObjectFile(Inside(stage, "bundle-files.json")))
using (var input = File.OpenRead(Inside(stage, entry.Key)))
if (Hash(input) != (string)entry.Value) throw new Exception("Повреждён компонент обновления: " + entry.Key);
for (int attempt = 0; ; attempt++)
{ try { Python(stage, "verify"); break; } catch { if (attempt == 2) throw; Thread.Sleep(700 * (attempt + 1)); } }
Python(stage, "configure");
var registrations = new Dictionary<string, string>();
string nextMarker = Inside(root, "installation.next.json");
try
{
if ((bool)previous["registered"])
foreach (string browser in Browsers)
{
string name = @"Software\" + browser + @"\NativeMessagingHosts\" + Host;
using (var registry = Registry.CurrentUser.OpenSubKey(name, true))
{
string value = registry == null ? null : registry.GetValue("") as string;
if (value == null || !String.Equals(PhysicalDirectory(Path.GetDirectoryName(value)), PhysicalDirectory(Path.Combine(current, ".runtime")), StringComparison.OrdinalIgnoreCase)) continue;
registrations[name] = value; registry.SetValue("", Path.Combine(stage, @".runtime\native-host.json"));
}
}
File.WriteAllText(nextMarker, Json.Serialize(new { product = Product, version = (string)metadata["version"], current = stage, registered = (bool)previous["registered"] }), Encoding.UTF8);
File.Replace(nextMarker, marker, null);
}
catch
{
foreach (var entry in registrations) using (var registry = Registry.CurrentUser.CreateSubKey(entry.Key)) registry.SetValue("", entry.Value);
if (File.Exists(nextMarker)) File.Delete(nextMarker);
throw;
}
report(100, "Новые компоненты подключены.");
}
static void Python(string root, string action)
{
var start = new ProcessStartInfo(Inside(root, @"runtime\python\python.exe"), "-I -X utf8 -B -m host.bootstrap " + action)
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 };
start.EnvironmentVariables["PYTHONPATH"] = "C:\\NONEXISTENT"; start.EnvironmentVariables["PYTHONHOME"] = "C:\\NONEXISTENT";
start.EnvironmentVariables["PYTHONDONTWRITEBYTECODE"] = "1";
start.EnvironmentVariables["PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32");
using (var process = Process.Start(start))
{
var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(90000)) throw new Exception("Проверка комплекта заняла слишком много времени.");
if (process.ExitCode != 0) throw new Exception("Комплект не прошёл проверку: " + stderr.Result + stdout.Result);
}
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern Microsoft.Win32.SafeHandles.SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern uint GetFinalPathNameByHandleW(Microsoft.Win32.SafeHandles.SafeFileHandle file, StringBuilder name, uint length, uint flags);
static string PhysicalDirectory(string path)
{
using (var handle = CreateFileW(path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero))
{
if (handle.IsInvalid) return Path.GetFullPath(path);
var name = new StringBuilder(32768);
if (GetFinalPathNameByHandleW(handle, name, (uint)name.Capacity, 0) == 0) return Path.GetFullPath(path);
string value = name.ToString();
return value.StartsWith(@"\\?\UNC\") ? @"\\" + value.Substring(8) : value.StartsWith(@"\\?\") ? value.Substring(4) : value;
}
}
static void Uninstall(string root, Action<int, string> report)
{
string marker = Inside(root, "installation.json");
if (!File.Exists(marker) || (string)ObjectFile(marker)["product"] != Product) throw new Exception("Установленный комплект YouTubeDL не найден.");
string physicalRoot = PhysicalDirectory(root);
report(5, "Отключаю помощник…");
foreach (string browser in Browsers)
{
string name = @"Software\" + browser + @"\NativeMessagingHosts\" + Host;
using (var registry = Registry.CurrentUser.OpenSubKey(name))
{
string value = registry == null ? null : registry.GetValue("") as string;
if (value == null || !PhysicalDirectory(Path.GetDirectoryName(value)).StartsWith(physicalRoot + "\\releases\\", StringComparison.OrdinalIgnoreCase)) continue;
}
Registry.CurrentUser.DeleteSubKey(name, false);
}
using (var registry = Registry.CurrentUser.OpenSubKey(UninstallKey))
{ if (registry != null && registry.GetValue("InstallLocation") is string && String.Equals(PhysicalDirectory((string)registry.GetValue("InstallLocation")), physicalRoot, StringComparison.OrdinalIgnoreCase)) Registry.CurrentUser.DeleteSubKeyTree(UninstallKey, false); }
string releases = Inside(root, "releases");
string[] packages = Directory.Exists(releases) ? Directory.GetDirectories(releases) : new string[0];
int done = 0;
foreach (string release in packages)
{
Inside(root, release.Substring(root.Length + 1) + "\\bundle.json");
if (!File.Exists(Path.Combine(release, "bundle.json")) || !File.Exists(Path.Combine(release, "bundle-files.json")) || (string)ObjectFile(Path.Combine(release, "bundle.json"))["product"] != Product) continue;
var records = ObjectFile(Path.Combine(release, "bundle-files.json"));
var directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string relative in records.Keys.Concat(new[] { "bundle-files.json", ".runtime/native-host.json", ".runtime/launch-host.cmd" }))
{
string path = Inside(release, relative);
if (File.Exists(path)) File.Delete(path);
for (string parent = Path.GetDirectoryName(path); parent != null && parent.StartsWith(release, StringComparison.OrdinalIgnoreCase); parent = Path.GetDirectoryName(parent)) directories.Add(parent);
}
foreach (string directory in directories.OrderByDescending(path => path.Length)) TryRemoveEmpty(directory);
report(10 + (++done * 80 / Math.Max(1, packages.Length)), "Удаляю встроенные компоненты…");
}
TryRemoveEmpty(releases);
string manager = Inside(root, "YouTubeDL-Manager.exe");
// Run the final cleanup outside the installed directory, after this EXE exits.
// The same mutex and an installation-marker check prevent a reinstall race.
if (File.Exists(manager))
{
string pending = Inside(root, "uninstall-pending.json");
File.WriteAllText(pending, Json.Serialize(new { product = Product }), Encoding.UTF8);
string cleanup = Path.Combine(Path.GetTempPath(), "YouTubeDL-Cleanup-" + Guid.NewGuid().ToString("N") + ".exe");
// Do not carry EFS attributes from AppData into the browser's temp
// directory; that directory may reside on a different volume.
File.WriteAllBytes(cleanup, File.ReadAllBytes(manager));
Process.Start(new ProcessStartInfo(cleanup, "--finish-uninstall --silent --target-root \"" + root + "\" --wait-pid " + Process.GetCurrentProcess().Id)
{ UseShellExecute = false, CreateNoWindow = true });
}
File.Delete(marker);
report(100, "Помощник удалён. Скачанные ролики сохранены.");
}
static void FinishRemoval(string root, Action<int, string> report)
{
string pending = Inside(root, "uninstall-pending.json");
if (!File.Exists(pending) || (string)ObjectFile(pending)["product"] != Product) return;
if (!File.Exists(Inside(root, "installation.json"))) File.Delete(Inside(root, "YouTubeDL-Manager.exe"));
File.Delete(pending); TryRemoveEmpty(root);
}
static void TryRemoveEmpty(string path)
{ try { if (Directory.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) == 0) Directory.Delete(path, false); } catch (IOException) { } }
}
internal sealed class SetupWindow : Form
{
readonly Action<Action<int, string>> work;
readonly Label message;
readonly ProgressBar progress;
readonly Button close;
bool working = true;
public int ExitCode = 0;
public SetupWindow(Action<Action<int, string>> task, bool uninstall)
{
work = task; Text = "YouTubeDL"; Size = new Size(560, 285); StartPosition = FormStartPosition.CenterScreen;
FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; Font = new Font("Segoe UI", 10);
bool light = true;
using (var theme = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"))
light = theme == null || Convert.ToInt32(theme.GetValue("AppsUseLightTheme", 1)) != 0;
BackColor = light ? Color.FromArgb(248, 249, 251) : Color.FromArgb(22, 25, 31);
ForeColor = light ? Color.FromArgb(25, 30, 40) : Color.FromArgb(235, 241, 248);
var title = new Label { Text = uninstall ? "Удаление помощника" : "Установка помощника", Location = new Point(28, 24), AutoSize = true, Font = new Font("Segoe UI", 17, FontStyle.Bold) };
var subtitle = new Label { Text = "YouTubeDL · автономный комплект " + "@BUNDLE_VERSION@", Location = new Point(30, 65), AutoSize = true };
message = new Label { Text = "Подготавливаю…", Location = new Point(30, 105), Size = new Size(475, 47) };
progress = new ProgressBar { Location = new Point(30, 154), Size = new Size(480, 8), Style = ProgressBarStyle.Continuous };
close = new Button { Text = "Закрыть", Location = new Point(394, 189), Size = new Size(115, 33), Enabled = false, FlatStyle = FlatStyle.Flat };
close.Click += (sender, args) => Close(); Controls.AddRange(new Control[] { title, subtitle, message, progress, close });
FormClosing += (sender, args) => { if (working) args.Cancel = true; };
Shown += async (sender, args) =>
{
try
{
await Task.Run(() => work((percent, text) => BeginInvoke(new Action(() => { progress.Value = Math.Max(0, Math.Min(100, percent)); message.Text = text; }))));
}
catch (Exception error) { ExitCode = 1; message.Text = error.Message; }
finally { working = false; close.Enabled = true; close.Focus(); }
};
}
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="YouTubeDL.Setup"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false"/></requestedPrivileges></security></trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3"><windowsSettings><longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware><dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware></windowsSettings></application>
<dependency><dependentAssembly><assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/></dependentAssembly></dependency>
</assembly>
+13
View File
@@ -0,0 +1,13 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$taskExpectedManifest = Join-Path $PSScriptRoot '.runtime\native-host.json'
foreach ($taskBrowser in @('Google\Chrome','Microsoft\Edge','Chromium')) {
$taskRegistryPath = "HKCU:\Software\$taskBrowser\NativeMessagingHosts\com.youtubedl.native"
if (Test-Path -LiteralPath $taskRegistryPath) {
$taskCurrentManifest = (Get-Item -LiteralPath $taskRegistryPath).GetValue('')
if ($taskCurrentManifest -eq $taskExpectedManifest) { Remove-Item -LiteralPath $taskRegistryPath }
}
}
Write-Host 'Native helper unregistered. Remove YouTubeDL from the browser extensions page.'
Write-Host 'Downloaded media files have not been removed.'