commit 5f2aad00f4332b611f9a658aa2b7cd79c84d8a1a Author: OwnerLink Date: Sat Oct 10 23:53:17 2026 +0300 Publish YouTubeDL 1.4.5 and website on Gitea diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..9d634f5 --- /dev/null +++ b/.github/workflows/checks.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c959ab6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.runtime/ +__pycache__/ +*.pyc +test-artifacts/ +dist/ +node_modules/ +.build/ +.build-cache/ +.venv/ +extension/helper-installer.exe +.env +.env.* +*.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..271d7a5 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 укажите версии ОС, браузера, +расширения и помощника, действия для воспроизведения и текст ошибки без личных данных. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..8ade056 --- /dev/null +++ b/INSTALL.md @@ -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». diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..32352f3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..3989e01 --- /dev/null +++ b/PRIVACY.md @@ -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, +токены, приватные ссылки и личные пути. Текст ошибки и скриншот не отправляются автоматически. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8e5947 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +

YouTubeDL — Видео. Звук. Ваш выбор.

+ +# 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 в файл. + +

Настройки YouTubeDL: качество, звук, формат и папка

+ +Скриншот показывает настоящий интерфейс с тестовыми метаданными. +Возможности конкретного ролика определяет 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)**. +Используйте скачивание для контента, который вы вправе сохранять, с учётом правил источника. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md new file mode 100644 index 0000000..3056248 --- /dev/null +++ b/THIRD_PARTY.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. diff --git a/docs/ASSETS.md b/docs/ASSETS.md new file mode 100644 index 0000000..5fd98c5 --- /dev/null +++ b/docs/ASSETS.md @@ -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. diff --git a/docs/RELEASE-1.4.1.md b/docs/RELEASE-1.4.1.md new file mode 100644 index 0000000..8bdccbf --- /dev/null +++ b/docs/RELEASE-1.4.1.md @@ -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 на широком и узком экранах, +контрольные суммы файлов релиза. diff --git a/docs/RELEASE-1.4.5.md b/docs/RELEASE-1.4.5.md new file mode 100644 index 0000000..727fc54 --- /dev/null +++ b/docs/RELEASE-1.4.5.md @@ -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) + +Собственный код распространяется по ограниченной пользовательской лицензии: разрешено использование по назначению, изменения и распространение требуют письменного разрешения правообладателя. Сторонние компоненты сохраняют свои лицензии. diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..1ceac67 --- /dev/null +++ b/extension/background.js @@ -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(() => {}); +}); diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..62065df --- /dev/null +++ b/extension/content.js @@ -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 = ``; + 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 = '
↓
'; + 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(() => {}); +})(); diff --git a/extension/controls.js b/extension/controls.js new file mode 100644 index 0000000..32b0305 --- /dev/null +++ b/extension/controls.js @@ -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 = ''; + const gear = ''; + 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 = `
`; + 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(); +})(); diff --git a/extension/downloader.html b/extension/downloader.html new file mode 100644 index 0000000..f6bf1e7 --- /dev/null +++ b/extension/downloader.html @@ -0,0 +1,108 @@ + + + + + YouTubeDL + + +
+
+
↓
YouTubeDLВидео. Звук. Ваш выбор.
+
+
+
+ Проверяем помощник…⌄ +
+

Проверяю связь с локальным помощником.

+

+
Скачать помощник
+ + + +
Состав комплекта Ещё не проверен
+
+
+
+
Проверяем вход в YouTube…
+

Используется вход из этого профиля браузера.

+
+
+
+ СЕЙЧАС НА YOUTUBE +

Подключаюсь к помощнику…

+

Выберите качество и сохраните ролик в один клик.

+ +
+ + +
Проверяю подключение…
+ + + +
Видеокарта ещё не проверена
+
+ +
ПЕРВЫЙ ЗАПУСК
+
  1. Знакомство
  2. Помощник
  3. Настройки
  4. Готово
+
+

Сохраняйте ролики в один клик

+
+

Сначала подключим помощник и выберем, как сохранять видео. Это займёт несколько шагов.

+ +

Загрузчик использует сохранённые настройки. «Скачать» запускает загрузку, а ⚙ рядом открывает выбор качества, формата и папки.

+
Кнопки появятся рядом с «Подписаться», на превью роликов и в Shorts.
+
+ + + + +
+ +
+ + + diff --git a/extension/downloader.js b/extension/downloader.js new file mode 100644 index 0000000..c9d339f --- /dev/null +++ b/extension/downloader.js @@ -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 = '
'; + 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 = '
'; + } + 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(); diff --git a/extension/history.css b/extension/history.css new file mode 100644 index 0000000..274fce6 --- /dev/null +++ b/extension/history.css @@ -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}} diff --git a/extension/history.html b/extension/history.html new file mode 100644 index 0000000..6697243 --- /dev/null +++ b/extension/history.html @@ -0,0 +1,35 @@ + + + + + История — YouTubeDL + + + + +
+
+
YouTubeDLИстория на этом устройстве
+ +
+

История

+

Скачанные файлы и сообщения об ошибках — в одном месте.

+
+ + +
+ + +
+

Загрузок пока нет

Здесь появятся завершённые и отменённые скачивания. Текущий прогресс остаётся в уведомлениях и настройках.

+
+
+ +
Записи хранятся только на этом устройстве: до 30 последних операций. Наличие записи не гарантирует, что файл остался в прежней папке.
+
+ + + diff --git a/extension/history.js b/extension/history.js new file mode 100644 index 0000000..3d4d6c1 --- /dev/null +++ b/extension/history.js @@ -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 = '

'; + 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)); diff --git a/extension/icons/128.png b/extension/icons/128.png new file mode 100644 index 0000000..a0a4400 Binary files /dev/null and b/extension/icons/128.png differ diff --git a/extension/icons/16.png b/extension/icons/16.png new file mode 100644 index 0000000..2bc6d83 Binary files /dev/null and b/extension/icons/16.png differ diff --git a/extension/icons/32.png b/extension/icons/32.png new file mode 100644 index 0000000..62ce475 Binary files /dev/null and b/extension/icons/32.png differ diff --git a/extension/icons/48.png b/extension/icons/48.png new file mode 100644 index 0000000..529c2f8 Binary files /dev/null and b/extension/icons/48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..64a3ccb --- /dev/null +++ b/extension/manifest.json @@ -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'" + } +} diff --git a/extension/onboarding.js b/extension/onboarding.js new file mode 100644 index 0000000..e6027e8 --- /dev/null +++ b/extension/onboarding.js @@ -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); + }, + }; +} diff --git a/extension/session.js b/extension/session.js new file mode 100644 index 0000000..2be698a --- /dev/null +++ b/extension/session.js @@ -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 } : {}) }; +} diff --git a/extension/shared.js b/extension/shared.js new file mode 100644 index 0000000..d0e53c6 --- /dev/null +++ b/extension/shared.js @@ -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 || "" }; +} diff --git a/extension/styles.css b/extension/styles.css new file mode 100644 index 0000000..ef4c6ba --- /dev/null +++ b/extension/styles.css @@ -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{width:17px;height:17px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}.jobs-section{border-top:1px solid var(--line);padding-top:14px;margin-top:17px}.job-card{padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;margin-top:8px;animation:enter .3s ease-out}.job-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}.job-title{font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px}.job-stage{font-size:10px;color:var(--muted);margin-top:4px}.job-percent{font-size:12px;font-variant-numeric:tabular-nums;color:var(--accent)}.progress-track{height:4px;border-radius:4px;overflow:hidden;background:var(--field);margin-top:10px}.progress-fill{height:100%;background:linear-gradient(90deg,#1c9cd5,#63e3e2);transition:width .3s ease;border-radius:4px;position:relative;overflow:hidden}.job-card[data-status=running] .progress-fill:not(.indeterminate):after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,#fff5,transparent);animation:shimmer 1.8s linear infinite}.progress-fill.indeterminate{width:35%!important;transition:none;animation:indeterminate 1.5s ease-in-out infinite}.job-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:7px}.job-actions button{font-size:10px;border:0;background:none;color:var(--accent);cursor:pointer;padding:2px}.job-error{color:var(--danger);font-size:10px;margin-top:7px;overflow-wrap:anywhere}.job-card[data-status=complete]{border-color:color-mix(in srgb,#32bf90 35%,var(--line))}.job-card[data-status=complete] .job-percent{color:#32bf90}footer{display:flex;justify-content:space-between;gap:8px;font-size:9px;color:var(--muted);letter-spacing:.5px;margin-top:17px;align-items:center}.status-dot{display:inline-block;width:5px;height:5px;border-radius:50%;background:#35cfa0;margin-right:5px;box-shadow:0 0 7px #35cfa044}[hidden]{display:none!important}.embedded-only{display:none}:root[data-embedded] .embedded-only{display:inline-block}:root[data-embedded] .app{padding:17px 18px 14px}:root[data-embedded] .header{margin-bottom:17px}:root[data-embedded] h1{font-size:17px}:root[data-embedded] .source{margin-bottom:14px} +@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)} diff --git a/extension/theme-init.js b/extension/theme-init.js new file mode 100644 index 0000000..abb0af8 --- /dev/null +++ b/extension/theme-init.js @@ -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; diff --git a/extension/ui-connection.js b/extension/ui-connection.js new file mode 100644 index 0000000..fb4dce5 --- /dev/null +++ b/extension/ui-connection.js @@ -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 }; +} diff --git a/host/__init__.py b/host/__init__.py new file mode 100644 index 0000000..34f258c --- /dev/null +++ b/host/__init__.py @@ -0,0 +1 @@ +"""YouTubeDL native messaging helper.""" diff --git a/host/bootstrap.py b/host/bootstrap.py new file mode 100644 index 0000000..9353982 --- /dev/null +++ b/host/bootstrap.py @@ -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() diff --git a/host/bundle.py b/host/bundle.py new file mode 100644 index 0000000..47c2f54 --- /dev/null +++ b/host/bundle.py @@ -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 /releases/. + 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)} diff --git a/host/common.py b/host/common.py new file mode 100644 index 0000000..9631978 --- /dev/null +++ b/host/common.py @@ -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 diff --git a/host/dependencies.py b/host/dependencies.py new file mode 100644 index 0000000..f7d0ee0 --- /dev/null +++ b/host/dependencies.py @@ -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() diff --git a/host/ffmpeg_progress.py b/host/ffmpeg_progress.py new file mode 100644 index 0000000..62aebee --- /dev/null +++ b/host/ffmpeg_progress.py @@ -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() diff --git a/host/folders.py b/host/folders.py new file mode 100644 index 0000000..952d5f8 --- /dev/null +++ b/host/folders.py @@ -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")) diff --git a/host/installation.py b/host/installation.py new file mode 100644 index 0000000..8a5d69a --- /dev/null +++ b/host/installation.py @@ -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} diff --git a/host/main.py b/host/main.py new file mode 100644 index 0000000..cab8328 --- /dev/null +++ b/host/main.py @@ -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()) diff --git a/host/media.py b/host/media.py new file mode 100644 index 0000000..1ebab43 --- /dev/null +++ b/host/media.py @@ -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 diff --git a/host/processes.py b/host/processes.py new file mode 100644 index 0000000..6f2af38 --- /dev/null +++ b/host/processes.py @@ -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 diff --git a/host/protocol.py b/host/protocol.py new file mode 100644 index 0000000..eb92fed --- /dev/null +++ b/host/protocol.py @@ -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(" MAX_MESSAGE: + raise ValueError("Ответ слишком большой.") + stream.write(struct.pack(" 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] diff --git a/host/updates.py b/host/updates.py new file mode 100644 index 0000000..a3eb3d2 --- /dev/null +++ b/host/updates.py @@ -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"]*>(.*?)", 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) diff --git a/host/worker.py b/host/worker.py new file mode 100644 index 0000000..e9e2575 --- /dev/null +++ b/host/worker.py @@ -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()) diff --git a/install.cmd b/install.cmd new file mode 100644 index 0000000..b65c56c --- /dev/null +++ b/install.cmd @@ -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 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..31bd5ea --- /dev/null +++ b/install.ps1 @@ -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.' +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fa1c728 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f156e1b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6c00641 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +yt-dlp[default]>=2026.8.19 diff --git a/site/.nojekyll b/site/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..71cbd32 --- /dev/null +++ b/site/app.js @@ -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); } +} diff --git a/site/assets/github-cover.png b/site/assets/github-cover.png new file mode 100644 index 0000000..9ffb786 Binary files /dev/null and b/site/assets/github-cover.png differ diff --git a/site/assets/hero.png b/site/assets/hero.png new file mode 100644 index 0000000..6e399ed Binary files /dev/null and b/site/assets/hero.png differ diff --git a/site/assets/mark.svg b/site/assets/mark.svg new file mode 100644 index 0000000..e115859 --- /dev/null +++ b/site/assets/mark.svg @@ -0,0 +1 @@ + diff --git a/site/assets/menu-dark.png b/site/assets/menu-dark.png new file mode 100644 index 0000000..56fb1a0 Binary files /dev/null and b/site/assets/menu-dark.png differ diff --git a/site/assets/menu-light.png b/site/assets/menu-light.png new file mode 100644 index 0000000..b5933fd Binary files /dev/null and b/site/assets/menu-light.png differ diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..47bd886 --- /dev/null +++ b/site/index.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + YouTubeDL — Видео. Звук. Ваш выбор. + + + + + + + +
+
+ +
+
+

ДЛЯ EDGE И CHROME · WINDOWS

+

Из YouTube.
В вашу папку.

+

Ролики, Shorts или только звук.
Выберите качество и формат —
остальное сделает YouTubeDL.

+ +

Бесплатно · Для Windows · Обработка на вашем компьютере

+
+
ВИДЕО. ЗВУК. ВАШ ВЫБОР.Выпуск 1.4.5
+
+
+

Один ролик.
Ваш формат.

MP4MKVWebMMP3M4AOpusFLACWAV
+
+
+

01 / ВСЁ ПОД РУКОЙ

+

Один клик.
Именно ваши
настройки.

+

Кнопка «Скачать» появляется рядом с «Подписаться», на превью и в Shorts. При нажатии загрузчик использует сохранённые параметры.

+
Скачать
+

Нужно изменить качество, звук или папку? Откройте ⚙ рядом. Без промежуточных меню при каждом скачивании.

+
  • Качество источникаРазрешение, FPS, HDR и язык аудио. Вплоть до 4K/8K, если они доступны у ролика.
  • Видео и звук вместеРаздельные дорожки автоматически соединяются. Можно сохранить только видео или только аудио.
  • Файл там, где удобноРабочая папка и папка сохранения могут находиться на разных дисках. Совпадающие имена получают номер; готовые файлы не перезаписываются.
+
+
+
ИНТЕРФЕЙС РАСШИРЕНИЯ
+
Меню YouTubeDL в тёмной теме: качество видео, звук, формат, кодек и папка сохранения
+
Настоящий интерфейс с тестовыми метаданными.
Тема меню подстраивается под YouTube; её можно сменить вручную.
+
+
+
+

02 / БЕЗ ЛИШНИХ ДЕЙСТВИЙ

Две дорожки.
Один готовый файл.

Помощник скачивает видео и звук, соединяет их и при необходимости меняет формат. Вы видите каждый этап.

Две операции одновременно, отмена и «Показать файл». Закрытие меню не прерывает скачивание. История загрузок и ошибок открывается в отдельной вкладке.

+
ВидеоЗвук
Готовый файлВ вашей папке
СкачатьОбъединитьСохранить

Совместимые дорожки копируются без перекодирования — это обычно быстрее всего.

+
+
+

03 / ЖЕЛЕЗО ИМЕЕТ ЗНАЧЕНИЕ

Если конвертировать,
то с ускорением.

NVIDIA NVENC и AMD AMF для H.264, HEVC и AV1. YouTubeDL определяет карту и проверяет кодировщики пробным запуском FFmpeg.

+
01

Проверка, а не наклейка

В меню показаны реальная карта и доступные кодеки. В «Авто» при необходимости используется CPU.

02

GPU ускоряет конвертацию

Скачивание зависит от сети и YouTube. Аудио и VP9 обрабатываются процессором.

03

Честно о проверках

NVENC проверен на RTX 5080. AMD AMF реализован; успешное кодирование на физической AMD-карте пока не проверено.

+
+
+

04 / НАЧНЁМ

Два скачивания.
Один комплект.

Windows 10/11 x64
Edge или Chrome 120+
Без прав администратора

+
+
1БРАУЗЕРv1.4.5

Расширение

Кнопки на YouTube, настройки и уведомления. Устанавливается распакованным.

Скачать расширение ZIP · Небольшой архив
+
2КОМПЬЮТЕРv1.3.2

Локальный помощник

Скачивает, соединяет и конвертирует. Все необходимые компоненты уже внутри.

Скачать помощник EXE · 124,3 МиБ · устанавливается отдельно
+
+

Оба файла размещены в релизах Gitea ↗. Помощник не встроен в ZIP. Проверка SHA256 и подробности.

+
    +
  1. 01

    Распакуйте и загрузите расширение

    Оставьте распакованный ZIP в постоянной папке. Откройте страницу расширений, включите «Режим разработчика» → «Загрузить распакованное расширение» → выберите папку extension.

  2. +
  3. 02

    Запустите помощник

    Откройте отдельно скачанный YouTubeDL-Helper-Setup.exe. Дождитесь установки. Python, yt-dlp, EJS, Node.js, FFmpeg и FFprobe уже в комплекте; для установки после скачивания интернет не нужен.

  4. +
  5. 03

    Обновите YouTube и пройдите мастер

    Откройте ⚙, нажмите «Проверить подключение», выберите папку, качество и формат. Зелёный индикатор подтверждает реальный ответ помощника. Затем загрузчик будет использовать эти настройки.

  6. +
+

Полная инструкция, обновление и удаление

+
+
+

05 / ПОНЯТНО О ДАННЫХ

Ваш компьютер.
Ваша обработка.

У проекта нет аналитики, рекламы и сервера для конвертации ваших роликов. Исходники доступны для просмотра.

Прочитать полный список данных и разрешений ↗
+
Что остаётся локально
Настройки, папка и до 30 последних операций в браузере: названия, ссылки, статусы, пути и ошибки. Помощник отдельно хранит до 30 готовых загрузок. GPU и версии компонентов проверяются для работы приложения.
Вход — только по согласию
Доступ к cookies YouTube необязателен. Они передаются локальному помощнику и используются в запросах к YouTube; в файлы cookies и историю не записываются. Разрешение можно отозвать.
Куда идут запросы
К YouTube и его медиасерверам, к официальным источникам обновлений и GitHub для скачиваний и сайта. Эти сервисы видят обычные данные сетевого запроса, включая IP.
+
+

ОСТАЛИСЬ ДЕТАЛИ?

Частые вопросы.

Если ответа здесь нет,
напишите в задачи проекта ↗

+
Почему нужен отдельный помощник?

Браузерное расширение не может запускать FFmpeg и напрямую работать с произвольной папкой Windows. Локальный Native Messaging помощник выполняет скачивание и обработку. Все зависимости собраны в один EXE; отдельно устанавливать Python или FFmpeg не нужно.

+
Как работают обновления?

Обновление компонентов проверяет и скачивает совместимые обновления Python, Node.js, yt-dlp с библиотеками, EJS и FFmpeg/FFprobe. Автопроверка включена по умолчанию: раз в сутки, пока помощник работает, между операциями. Её можно выключить. Новые версии самого расширения и кода помощника устанавливаются вручную из Releases.

+
Нужно входить в YouTube?

Для многих открытых роликов — нет. Если YouTube требует вход или проверку, откройте ролик в текущем профиле и при необходимости включите «Использовать вход в YouTube». Вход на сайте сам по себе не авторизует отдельный загрузчик. Авторизация не гарантирует обход ограничений.

+
Будет ли работать 4K, 8K и HDR?

Если эти дорожки есть у источника. Разрешение не увеличивается искусственно. Для HDR используйте исходный кодек, HEVC или AV1; HDR → H.264 без тонального преобразования отклоняется. FLAC/WAV тоже не восстанавливают уже потерянное качество звука.

+
Как удалить всё?

Удалите помощник кнопкой в меню или через приложения Windows, затем расширение из браузера. Скачанные файлы и история помощника сохраняются. Для удаления истории после удаления помощника удалите %LOCALAPPDATA%\YouTubeDL\Helper\data. Настройки расширения удаляются вместе с расширением.

+
Есть другие ОС или магазин расширений?

Сейчас опубликована сборка для Windows 10/11 x64 и Edge/Chrome 120+. Firefox, macOS, Linux и Windows ARM не входят в неё. В магазинах браузеров расширение пока не опубликовано. Плейлисты, скачивание каналов и DRM-контент не заявляются.

+
Можно изменять расширение и публиковать свои версии?

Лицензия разрешает использование по назначению и запрещает изменения, самостоятельные форки и распространение собственного кода без письменного разрешения, с учётом исключений в её тексте. Сторонние компоненты сохраняют свои лицензии.

+
+

Видео. Звук.
Ваш выбор.

Скачать YouTubeDL
+
+
+ + + diff --git a/site/robots.txt b/site/robots.txt new file mode 100644 index 0000000..fdde3f9 --- /dev/null +++ b/site/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://git.lonarpgproxy.space/extention/youtubedl/sitemap.xml diff --git a/site/sitemap.xml b/site/sitemap.xml new file mode 100644 index 0000000..002e250 --- /dev/null +++ b/site/sitemap.xml @@ -0,0 +1,2 @@ + +https://git.lonarpgproxy.space/extention/youtubedl/ diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..8db844c --- /dev/null +++ b/site/styles.css @@ -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}} +} diff --git a/site/theme.js b/site/theme.js new file mode 100644 index 0000000..80580fe --- /dev/null +++ b/site/theme.js @@ -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. */ } +})(); diff --git a/tests/amd-fallback.py b/tests/amd-fallback.py new file mode 100644 index 0000000..8f7ba7e --- /dev/null +++ b/tests/amd-fallback.py @@ -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.") diff --git a/tests/browser-components.test.mjs b/tests/browser-components.test.mjs new file mode 100644 index 0000000..723b55c --- /dev/null +++ b/tests/browser-components.test.mjs @@ -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()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}); +} diff --git a/tests/browser-native.test.mjs b/tests/browser-native.test.mjs new file mode 100644 index 0000000..5c01788 --- /dev/null +++ b/tests/browser-native.test.mjs @@ -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: '

Me at the zoo

', + })); + 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 }); + } +} diff --git a/tests/component-update.py b/tests/component-update.py new file mode 100644 index 0000000..19694bf --- /dev/null +++ b/tests/component-update.py @@ -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() diff --git a/tests/extension.test.mjs b/tests/extension.test.mjs new file mode 100644 index 0000000..fd8edf6 --- /dev/null +++ b/tests/extension.test.mjs @@ -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("")); +}); +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"); +}); diff --git a/tests/helper-lifecycle.test.mjs b/tests/helper-lifecycle.test.mjs new file mode 100644 index 0000000..a5fdd04 --- /dev/null +++ b/tests/helper-lifecycle.test.mjs @@ -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: "

релизов проекта test destination

" })); + 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 }); +} diff --git a/tests/helper-reconnect.test.mjs b/tests/helper-reconnect.test.mjs new file mode 100644 index 0000000..e8a5dc5 --- /dev/null +++ b/tests/helper-reconnect.test.mjs @@ -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 }); +} diff --git a/tests/history-browser.test.mjs b/tests/history-browser.test.mjs new file mode 100644 index 0000000..0afa716 --- /dev/null +++ b/tests/history-browser.test.mjs @@ -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: '', status: "error", url: "https://evil.test/", error: 'Ошибка \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 }); +} diff --git a/tests/history.test.mjs b/tests/history.test.mjs new file mode 100644 index 0000000..69fe44c --- /dev/null +++ b/tests/history.test.mjs @@ -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")); +}); diff --git a/tests/installed-helper.mjs b/tests/installed-helper.mjs new file mode 100644 index 0000000..6faae5c --- /dev/null +++ b/tests/installed-helper.mjs @@ -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)); +} diff --git a/tests/integration.py b/tests/integration.py new file mode 100644 index 0000000..56b8acb --- /dev/null +++ b/tests/integration.py @@ -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() diff --git a/tests/live-shorts.test.mjs b/tests/live-shorts.test.mjs new file mode 100644 index 0000000..dd0214c --- /dev/null +++ b/tests/live-shorts.test.mjs @@ -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}); +} diff --git a/tests/native-connection.test.mjs b/tests/native-connection.test.mjs new file mode 100644 index 0000000..a7a40ab --- /dev/null +++ b/tests/native-connection.test.mjs @@ -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'); +}); diff --git a/tests/offline-bundle.py b/tests/offline-bundle.py new file mode 100644 index 0000000..33e18d7 --- /dev/null +++ b/tests/offline-bundle.py @@ -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.") diff --git a/tests/onboarding.test.mjs b/tests/onboarding.test.mjs new file mode 100644 index 0000000..e245c9a --- /dev/null +++ b/tests/onboarding.test.mjs @@ -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(); } diff --git a/tests/progress-motion.test.mjs b/tests/progress-motion.test.mjs new file mode 100644 index 0000000..26d595e --- /dev/null +++ b/tests/progress-motion.test.mjs @@ -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 }); +} diff --git a/tests/session.test.mjs b/tests/session.test.mjs new file mode 100644 index 0000000..381d5da --- /dev/null +++ b/tests/session.test.mjs @@ -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); +}); diff --git a/tests/site.test.mjs b/tests/site.test.mjs new file mode 100644 index 0000000..ca8968e --- /dev/null +++ b/tests/site.test.mjs @@ -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)); } diff --git a/tests/test-native-registration.ps1 b/tests/test-native-registration.ps1 new file mode 100644 index 0000000..d3911c5 --- /dev/null +++ b/tests/test-native-registration.ps1 @@ -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.' } +} diff --git a/tests/test_host.py b/tests/test_host.py new file mode 100644 index 0000000..8998475 --- /dev/null +++ b/tests/test_host.py @@ -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(" 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() diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..d53bbd4 --- /dev/null +++ b/tests/test_updates.py @@ -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 = '3.14161514' + def response(url): + if url.endswith("/ftp/python/"): return SimpleNamespace(text=index) + if "31316" in url: return SimpleNamespace(text="Source only") + return SimpleNamespace(text=f'Windows embed{checksum}') + 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() diff --git a/tests/thumbnail-placement.test.mjs b/tests/thumbnail-placement.test.mjs new file mode 100644 index 0000000..714f536 --- /dev/null +++ b/tests/thumbnail-placement.test.mjs @@ -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(''); +await context.route("https://www.youtube.com/**", route => route.fulfill({ contentType: "text/html", body: `
+Shorts previewТЫ НЕ ВСТРЕЧАЛ этого Левиафана в Subnautica 2...
1,6 млн просмотров
+
Video preview
S.T.A.L.K.E.R. — видео и описание
+Modern previewНазвание внутри ссылки
Информация под превью
+
` })); +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 = ''; + 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(); } diff --git a/tests/ui-connection.test.mjs b/tests/ui-connection.test.mjs new file mode 100644 index 0000000..4533b5b --- /dev/null +++ b/tests/ui-connection.test.mjs @@ -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); + } +}); diff --git a/tests/ui-fixture.js b/tests/ui-fixture.js new file mode 100644 index 0000000..d554e1d --- /dev/null +++ b/tests/ui-fixture.js @@ -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: "Помощник отключён." } })); +})(); diff --git a/tests/ui.test.mjs b/tests/ui.test.mjs new file mode 100644 index 0000000..8d9a36d --- /dev/null +++ b/tests/ui.test.mjs @@ -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: `
Deep learning · chapter 1

But what is a neural network?

3Blue1BrownНаглядная математика

Тест интерфейса YouTubeDL

` }); + 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 = 'Test video'; + 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 = 'Short'; + preview.addEventListener('mouseenter', () => { + const replacement=document.createElement('a'); replacement.href='/shorts/Mf9GCn_LsUI'; replacement.style.cssText='display:block;height:200px'; + replacement.innerHTML=''; + 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 = '
'; + 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(); } diff --git a/tests/version-compatibility.test.mjs b/tests/version-compatibility.test.mjs new file mode 100644 index 0000000..d261d85 --- /dev/null +++ b/tests/version-compatibility.test.mjs @@ -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(); +} diff --git a/tools/build.py b/tools/build.py new file mode 100644 index 0000000..f229263 --- /dev/null +++ b/tools/build.py @@ -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() diff --git a/tools/build_bundle.py b/tools/build_bundle.py new file mode 100644 index 0000000..7df36f3 --- /dev/null +++ b/tools/build_bundle.py @@ -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() diff --git a/tools/capture-site-ui.mjs b/tools/capture-site-ui.mjs new file mode 100644 index 0000000..ca80b18 --- /dev/null +++ b/tools/capture-site-ui.mjs @@ -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(); } diff --git a/tools/collect_release_materials.py b/tools/collect_release_materials.py new file mode 100644 index 0000000..45dc34d --- /dev/null +++ b/tools/collect_release_materials.py @@ -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() diff --git a/tools/run-installer-test.ps1 b/tools/run-installer-test.ps1 new file mode 100644 index 0000000..993d8b1 --- /dev/null +++ b/tools/run-installer-test.ps1 @@ -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 +} diff --git a/tools/setup.cs b/tools/setup.cs new file mode 100644 index 0000000..5b9f4f8 --- /dev/null +++ b/tools/setup.cs @@ -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(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> 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 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 ObjectFile(string path) + { return Json.Deserialize>(File.ReadAllText(path, Encoding.UTF8)); } + +#if SETUP + static void Install(string root, bool register, Action 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 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 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 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(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 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> work; + readonly Label message; + readonly ProgressBar progress; + readonly Button close; + bool working = true; + public int ExitCode = 0; + public SetupWindow(Action> 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(); } + }; + } +} diff --git a/tools/setup.manifest b/tools/setup.manifest new file mode 100644 index 0000000..4511c0c --- /dev/null +++ b/tools/setup.manifest @@ -0,0 +1,7 @@ + + + + + truetrue + + diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..c3f1ae4 --- /dev/null +++ b/uninstall.ps1 @@ -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.'