Publish YouTubeDL 1.4.5 and website on Gitea
Check extension and helper / check (push) Canceled after 0s
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
.runtime/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
test-artifacts/
|
||||
dist/
|
||||
node_modules/
|
||||
.build/
|
||||
.build-cache/
|
||||
.venv/
|
||||
extension/helper-installer.exe
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -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 укажите версии ОС, браузера,
|
||||
расширения и помощника, действия для воспроизведения и текст ошибки без личных данных.
|
||||
@@ -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».
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
токены, приватные ссылки и личные пути. Текст ошибки и скриншот не отправляются автоматически.
|
||||
@@ -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)**.
|
||||
Используйте скачивание для контента, который вы вправе сохранять, с учётом правил источника.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 на широком и узком экранах,
|
||||
контрольные суммы файлов релиза.
|
||||
@@ -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)
|
||||
|
||||
Собственный код распространяется по ограниченной пользовательской лицензии: разрешено использование по назначению, изменения и распространение требуют письменного разрешения правообладателя. Сторонние компоненты сохраняют свои лицензии.
|
||||
@@ -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(() => {});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 228 B |
|
After Width: | Height: | Size: 374 B |
|
After Width: | Height: | Size: 540 B |
@@ -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'"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
@@ -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 || "" };
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""YouTubeDL native messaging helper."""
|
||||
@@ -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()
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
yt-dlp[default]>=2026.8.19
|
||||
@@ -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); }
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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 |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://git.lonarpgproxy.space/extention/youtubedl/sitemap.xml
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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. */ }
|
||||
})();
|
||||
@@ -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.")
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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.")
|
||||
@@ -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(); }
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)); }
|
||||
@@ -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.' }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(); }
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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: "Помощник отключён." } }));
|
||||
})();
|
||||
@@ -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(); }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(); }
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(); }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.'
|
||||