yt-dlp для мамы: как я упаковал консольную утилиту в программу для Windows и на какие грабли наступил

в 18:20, , рубрики: ffmpeg, flask, python, windows, yt-dlp
yt-dlp для мамы: как я упаковал консольную утилиту в программу для Windows и на какие грабли наступил - 1

У меня в семье регулярно звучит один и тот же вопрос: «А как скачать вот это видео?». Ответ «поставь yt-dlp» не работает: консоль, Python, ffmpeg, ключи командной строки — для человека, который просто хочет песню в машину, это стена. Готовые программы есть, но у одних реклама и платное «Pro», другие заброшены, третьи — онлайн-сервисы с баннерами, которые сами тянут файл через свой сервер.

Я поставил себе простую цель: один zip, двойной щелчок — и всё работает. Без установки Python, без рекламы, без регистрации и без чужих серверов. Внутри — тот же yt-dlp, а снаружи — понятная страница с кнопками. Получился «Универсальный скачиватель». Ниже — не столько про то, что он умеет, сколько про то, обо что я спотыкался по дороге: почти каждая «простая» задача на Windows оказалась с подвохом.

Что получилось

Вставляешь ссылку или название песни, выбираешь качество — получаешь MP4, MP3 или M4A. Что умеет программа:

  • всё, что умеет yt-dlp: YouTube, VK, Rutube, Одноклассники, Дзен, TikTok, Instagram, SoundCloud и ещё 1000+ сайтов;

  • поиск музыки сразу на YouTube, SoundCloud и Rutube одним списком и прослушивание перед скачиванием;

  • плейлисты, списки ссылок, выбор звуковой дорожки и субтитров, сжатие скачанного видео в H.265;

  • расширение для Chrome, Edge, Яндекс Браузера, Оперы и Firefox — кнопка «Скачать» прямо на сайте;

  • если сайт не открывается — программа сама находит прокси VPN-клиента;

  • обновляет yt-dlp раз в сутки, а себя — с GitHub, с откатом, если новая версия не запустилась.

Вкладка «Музыка»

Вкладка «Музыка»
Тёмная тема

Тёмная тема

Архитектура: зачем тут браузер

 значок у часов (pystray)
          │
   Flask на 127.0.0.1:8765  ←── страница в браузере (HTML + JS, без фреймворков)
          │                 ←── расширение браузера / букмарклет
   yt-dlp ── ffmpeg ── Deno

Интерфейс — обычная страница, которую отдаёт Flask на 127.0.0.1. Сама программа живёт значком у часов, окна у неё нет. Почему не Qt и не Electron:

  • архив программы весит 170 КБ, всё тяжёлое докачивается при первом запуске;

  • браузер уже есть у всех, и он прекрасно рисует интерфейс, тёмную тему и узкие экраны;

  • расширению и букмарклету проще всего общаться с программой по HTTP — та же страница, тот же API.

Тяжёлые зависимости ставятся через pip: ffmpeg приезжает пакетом imageio-ffmpeg, Deno — пакетом deno (без JS-движка YouTube отдаёт не все форматы). Получается, что один requirements.txt описывает вообще всё:

yt-dlp[default,curl-cffi]
flask
imageio-ffmpeg
deno
pystray
pillow

curl-cffi нужен для TikTok и Instagram: без браузерного TLS-отпечатка они отвечают «Unexpected response» и «empty media response».

Грабли 1. Python без Python

Просить пользователя ставить Python нельзя. Брать системный — тоже: у одного 3.8, у другого Microsoft Store-заглушка, у третьего сломанный PATH. Поэтому программа ставит свой Python — встроенный дистрибутив (embeddable zip) с python.org — прямо в свою папку. У embeddable-версии оказалось несколько сюрпризов.

Первый — в нём нет pip, а запустить pip из wheel-файла не получится: он ругается «To modify pip, please run…». Работает так: распаковать wheel в site-packages, а потом python -m pip install --force-reinstall того же wheel — уже «по-честному».

Второй — он не видит папку скрипта. Путь поиска модулей задаётся файлом python312._pth, и каталог запускаемого скрипта туда сам не попадает, так что мой sites.py рядом с app.py просто не импортировался. Итоговый _pth, где .. — папка программы, а import site включает Libsite-packages:

python312.zip
.
..
import site

Третий — он работает в изолированном режиме: переменные окружения PYTHON* (включая PYTHONIOENCODING) не действуют, и отлаживать кодировки приходится иначе. И четвёртый — в нём нет tkinter, поэтому диалог выбора папки пришлось делать через FolderBrowserDialog из PowerShell.

Раз уж скрипт что-то качает из интернета и запускает, он это проверяет. SHA-256 архива Python и wheel-файла pip зашиты в установщик, а у python.exe и python312.dll дополнительно проверяется цифровая подпись. Python собирается во временной папке python.new и переименовывается только после всех проверок — оборванная установка не оставит полуживой интерпретатор.

$sig = Get-AuthenticodeSignature (Join-Path $stage $name)
if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notmatch 'O=Python Software Foundation') {
    throw "$name не прошёл проверку подписи ($($sig.Status))"
}

Грабли 2. Запуск на Windows: кириллица и мигающие окна

Первая версия запускалась файлом Запуск.bat с кириллицей внутри. Оказалось, cmd читает .bat-файлы кусками и после call/goto с многобайтной кодировкой начинает рвать строки посередине — получаются загадочные «команда не найдена» с обрывками русских слов. Решение: .bat — только ASCII и одна строка «запусти PowerShell-скрипт», вся логика — в start.ps1. Сам .ps1 — строго в UTF-8 с BOM, иначе Windows PowerShell 5.1 читает его в системной кодировке.

Вторая беда — окна. Ярлык на рабочем столе запускает launch.vbs, а тот — Python со скрытым окном. Логичный вариант — pythonw.exe, у него консоли нет вовсе. Но тогда каждый дочерний процесс (ffmpeg, Deno) создаёт свою консоль, и при каждом скачивании на экране мигают чёрные окна. Правильно — обычный python.exe со спрятанной консолью (WScript.Shell.Run …, 0): дочерние процессы наследуют её и тоже не видны.

Третья — фокус. Окно выбора папки, запущенное фоновым процессом, Windows упорно открывает за браузером: системе не нравится, когда фоновое приложение крадёт фокус. Помог классический приём — присоединиться к потоку активного окна через AttachThreadInput и уже оттуда вызвать SetForegroundWindow. С Проводником («Показать файл») та же история: уже открытую папку он только подсвечивает на панели задач, поэтому программа сама находит его окно и выводит вперёд.

Грабли 3. Локальный сервер — это дыра, если о ней не подумать

«Сервер слушает только 127.0.0.1, значит, снаружи до него не достучаться» — это опасное заблуждение. До него может достучаться любая страница, открытая в вашем браузере: fetch('http://127.0.0.1:8765/api/download', …) с любого сайта уходит на ваш компьютер. А программа умеет сохранять файлы в выбранную папку. Атак тут две:

  1. Прямой запрос с чужой страницы. Браузер не даст прочитать ответ (CORS), но сам запрос-то отправит.

  2. DNS rebinding. Домен злоумышленника сначала указывает на его сервер, а потом — на 127.0.0.1. Для браузера это «тот же сайт», и CORS уже не спасает.

Защита — один before_request: проверка заголовка Host (против DNS rebinding), заголовков Origin и Sec-Fetch-Site (против запросов с чужих страниц) и требование JSON для POST.

Код проверки запросов
@app.before_request
def only_own_page():
    if request.host.lower() not in LOCAL_HOSTS:  # DNS rebinding: Host будет чужим
        return Response('Forbidden', 403, mimetype='text/plain')
    if not request.path.startswith('/api/'):
        return None
    origin = request.headers.get('Origin')
    if (origin and origin not in LOCAL_ORIGINS) or 
            request.headers.get('Sec-Fetch-Site') in ('cross-site', 'same-site'):
        return jsonify(error='Запрос с чужой страницы.'), 403
    if request.method == 'POST' and not request.is_json:
        return jsonify(error='Нужен JSON.'), 415
    return None

Последняя проверка — самая хитрая. Content-Type: application/json не входит в список «простых» запросов, поэтому браузер перед таким запросом с чужого сайта обязан спросить разрешения (CORS preflight) — и не получит его. А обычная HTML-форма JSON отправить не умеет. То есть требование «только JSON» само по себе отсекает целый класс CSRF. Вдобавок есть заголовки X-Frame-Options: SAMEORIGIN (страницу нельзя встроить в чужой iframe) и Referrer-Policy: no-referrer (сайтам с обложками незачем знать адрес программы).

Отдельная история — расширение и букмарклет: им как раз нужно передать ссылку с чужого сайта. Они делают это не запросом к API, а открытием страницы программы с адресом в URL (/?url=… или #url=… для уже открытой вкладки) — то есть действием, которое пользователь видит.

Грабли 4. «Послушать перед скачиванием»

Казалось бы: yt-dlp выдаёт прямую ссылку на звуковую дорожку, кладём её в <audio src> — готово. Не тут-то было. Ссылка на googlevideo.com выписывается под конкретный клиент и привязана к IP и заголовкам: браузер с другими заголовками (а если включён прокси — и с другим IP) получает 403. Поэтому поток идёт через Flask: /api/stream сам ходит к YouTube и отдаёт браузеру байты, поддерживая Range — иначе не работает перемотка. Целиком YouTube отдаёт файл медленно, поэтому у сайта программа просит куски по 10 МБ, а браузеру отдаёт непрерывный поток.

Самый загадочный баг: песня не включалась с первого раза, а со второго — всегда. Первые секунды ссылка отвечала 403. Оказалось, у части форматов YouTube есть поле available_at — момент, раньше которого ссылка не работает (около 5 секунд). Сам yt-dlp при скачивании это время честно ждёт, а я — нет:

# при скачивании yt-dlp ждёт сам (available_at), для прослушивания ждём здесь
delay = (info.get('available_at') or 0) - time.time()

SoundCloud и Rutube отдают только HLS — кусочки, которые через Range не отдать. Для них песня скачивается во временную папку целиком (ffmpeg, без видео), а уже готовый файл отдаётся с Range. А в Safari audio.play() нужно вызывать прямо в обработчике щелчка, а не после await fetch(...) — иначе Safari считает это автозапуском и молча запрещает.

Грабли 5. Ошибки, понятные человеку

yt-dlp пишет ошибки для программистов. Пользователю нужно другое: «что случилось и что делать». Поэтому в программе есть список правил «регулярное выражение → объяснение по-русски», а исходный текст прячется под кнопку «Подробнее». Первое, что выяснилось: порядок правил важен. yt-dlp дописывает «Use --cookies…» к самым разным ошибкам, и если правило «нужен вход» стоит первым, оно перехватывает всё подряд — и возрастные ограничения, и «вы не бот», и платный контент.

Второе — мой любимый ребус. Иногда скачивание падало с текстом

ffmpeg exited with code 3436169992

Что это за число? Оказалось, это HTTP 403. ffmpeg кодирует свои ошибки как отрицательное число из четырёх байт-тегов:

#define MKTAG(a,b,c,d)   ((a) | ((b) << 8) | ((c) << 16) | ((unsigned)(d) << 24))
#define FFERRTAG(a,b,c,d) (-(int)MKTAG(a, b, c, d))
#define AVERROR_HTTP_FORBIDDEN FFERRTAG(0xF8,'4','0','3')

Проверяем на Python:

>>> tag = 0xF8 | ord('4') << 8 | ord('0') << 16 | ord('3') << 24
>>> (-tag) & 0xFFFFFFFF   # код выхода процесса в Windows — беззнаковый
3436169992

Буквально «403», зашитое в байты. По той же схеме 3419392776 — это 404. Теперь вместо магического числа пользователь видит «Сайт не дал скачать (ошибка 403)…» с советом, что делать.

Грабли 6. Когда yt-dlp не справляется

В yt-dlp больше тысячи разборщиков сайтов, но часть из них сломана, а некоторых сайтов там нет совсем. Хорошая новость: разборщики — это обычные классы, и их можно подменять. Если положить свой класс с тем же ключом в yt_dlp.globals.extractors, он заменит встроенный; а свои новые можно поставить в начало списка. Несколько примеров из жизни:

  • Одноклассники. Сайт поменял формат: flashvars.metadata стал объектом, а не JSON-строкой, и встроенный разборщик падал с «the JSON object must be str… not dict». Хватило подкласса в пять строк: если пришёл уже готовый объект — вернуть его как есть.

  • ЯП файлы. Встроенный разборщик брал один общий ключ для API, а у каждого ролика он свой — получалось 403. Мой берёт ключ со страницы плеера конкретного ролика.

  • Instagram-карусели. Если в посте вперемешку фото и видео, фото без форматов роняли весь пост. Подкласс просто убирает их из списка.

  • Видео в чужих iframe-плеерах. Если на странице ничего не нашлось, общий разборщик теперь заходит на один уровень в iframe плеера (с Referer исходной страницы) — так находятся, например, KVS-плееры.

Отдельный совет: если ваш разборщик возвращает одиночное видео с полем url вместо списка formats, часть логики выбора качества может неожиданно получить пустой список. Отдавайте formats всегда.

Грабли 7. Заблокированные сайты

У многих пользователей стоит VPN-расширение в браузере. Но программа — не браузер, и на неё оно не действует. Зато у многих есть и VPN-клиенты (v2rayN, Hiddify, NekoBox, Clash…), которые поднимают локальный прокси на известных портах. Логика такая: если ошибка похожа на блокировку (таймаут, сброс соединения, SSL EOF, HTTP 410), программа:

  1. берёт прокси из настроек, а если его нет — перебирает типичные порты VPN-клиентов (10809, 10808, 2080, 7890, 1080…);

  2. проверяет найденный прокси запросом к generate_204 YouTube;

  3. повторяет скачивание через него и запоминает сайт, если через прокси он открылся.

Для случая, когда провайдер ломает DNS, есть галочка «Защищённый DNS». Реализация — подмена socket.getaddrinfo для всего процесса: сначала системный DNS, а при неудаче — DNS-over-HTTPS. Запрос к DoH идёт к Cloudflare и Google по IP-адресу (иначе для DoH понадобился бы DNS), ответы кэшируются по TTL. Ограничения честно описаны в справке: ffmpeg и curl_cffi резолвят имена сами, и эта подмена на них не действует.

Код подмены getaddrinfo
def getaddrinfo_doh(host, port, family=0, type=0, proto=0, flags=0):
    try:
        return system_getaddrinfo(host, port, family, type, proto, flags)
    except socket.gaierror:
        name = ...  # нормализованное имя, если галочка включена
        ips = doh_lookup(name, 1) if name and family != socket.AF_INET6 else []
        if name and not ips and family != socket.AF_INET:
            ips = doh_lookup(name, 28)  # AAAA
        if not ips:
            raise
        return [info for ip in ips
                for info in system_getaddrinfo(ip, port, family, type, proto,
                                               flags | socket.AI_NUMERICHOST)]

socket.getaddrinfo = getaddrinfo_doh

Грабли 8. Самообновление, которое не убивает программу

Сайты постоянно меняются, поэтому yt-dlp программа обновляет раз в сутки сама (pip install -U). А вот обновлять саму программу оказалось сложнее: файлы работающей программы не заменить, а сломанное обновление у человека, который не знает слова «консоль», — это конец. Схема получилась такая:

  1. Раз в три дня — запрос к GitHub API releases/latest. Новый тег — значок у часов и плашка на странице.

  2. По кнопке — скачать zip, сверить SHA-256 из поля digest ассета GitHub, распаковать и проверить: все нужные файлы на месте, версия в архиве совпадает с тегом, все .py компилируются.

  3. Запустить копию updater.py отдельным процессом и завершиться.

  4. Апдейтер ждёт выхода программы, переносит старые файлы в updateold (личные — настройки, cookies, скачанное, свой Python — не трогает), кладёт новые и запускает программу.

  5. Дальше он ждёт, пока новая версия ответит на /api/status с правильным номером. Не ответила за 3 минуты и порт пуст — откат: старые файлы на место, запуск старой версии. Итог пишется в updateresult.json, страница показывает его после перезапуска.

Апдейтер написан только на стандартной библиотеке: он должен работать, даже если обновление сломало зависимости. Проверял это на «фальшивом GitHub»: маленький скрипт собирает архив с версией 1.3, python -m http.server раздаёт его вместе с latest.json, а адрес API подменяется переменной окружения. Отдельный вариант архива падает при запуске — чтобы проверить откат.

Как это тестировалось

Половина багов в таком проекте — в браузерах, поэтому есть прогон страниц на Selenium в headless-режиме: Chrome, Edge, Firefox и Яндекс Браузер. Проверяются разбор ссылок, поиск, плеер, тёмная тема, узкий экран и передача ссылки с чужой страницы. Самым неожиданным оказалось тестирование расширения:

  • фирменный Chrome с версии 137 игнорирует --load-extension, поэтому тест берёт Chrome for Testing той же версии;

  • обработчики расширения вызываются прямо в его service worker через DevTools-протокол;

  • Firefox не принимает один background.service_worker — в манифесте рядом лежит background.scripts, а Chrome и Edge его молча игнорируют;

  • в Firefox execute_script на странице расширения не видит const, только function.

Итоги

  • ~6,7 тысячи строк: сервер на Flask (~2,4 тыс.), свои разборщики для yt-dlp (~750), апдейтер, заставка на чистом Win32 через ctypes, установщик на PowerShell и одна HTML-страница без фреймворков;

  • архив — 170 КБ, после установки — около 350 МБ (в основном Python, ffmpeg и Deno);

  • Windows 10/11, проверено в Chrome, Edge, Firefox, Яндекс Браузере и Опере.

Чего пока нет: английского интерфейса, версий для Linux и macOS, Яндекс Музыки (встроенный разборщик yt-dlp сломан, а без подписки там только отрывки) и VK-поиска (без входа он закрыт). Главный вывод для себя: обернуть консольную утилиту в «кнопку» — это 10% работы. Остальные 90% — сделать так, чтобы у человека, который ничего не знает про Python, PATH и кодировки, всё запустилось с первого раза и само объяснило, если что-то пошло не так.

Программа просто скачивает то, что сайт и так показывает в браузере, и не обходит DRM. Скачивайте только то, на что у вас есть право.

Скачать программу можно на странице последней версии на GitHub, исходники лежат там же: github.com/OlegChe77/universal-downloader. Буду рад замечаниям — и по коду, и по удобству. Если какой-то сайт не качается, напишите в комментариях ссылку и текст ошибки из кнопки «Подробнее»: так проще всего починить.

Автор: OlegChe77

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js