yt-dlp-gui/README.md
loop-uh 7564df4f95 YouTube: сторож прямого пути — при блокировке по SNI сразу через egress, а не «Проверяем…» навсегда
13.10.2026 провайдер начал молча глотать TLS с именем youtube.com. Ошибки
при этом нет: yt-dlp ждёт по 30 с на запрос и повторяет, /api/info не
отвечал дольше 90 с, запасной путь через egress включался только по тексту
бана. YouTube лежал около 16 часов, /readyz всё это время был «ок».

- downloader.yt_direct_ok: короткий запрос HEAD /generate_204 к
  www.youtube.com с YTG_YT_SOURCE_IP (таймаут 4 с, отказ — две неудачи
  подряд), результат кэшируется, обновляет его уборщик. Пока путь молчит,
  разбор ролика и списка идут через egress сразу; путь загрузки выбирает
  сервер (yt_egress_fallback), а не признак от клиента.
- В журнале строка на смену состояния ([youtube] напрямую не отвечает /
  снова отвечает); /readyz остаётся «ок», но несёт warnings и состояние в
  метриках. Выключатель: YTG_YT_REACH_CHECK=0.
- Пустой ответ через egress в режиме обхода повторяется один раз: туннель
  бывает медленным.
- Туннель пула для разбора через egress выбирается живой и случайный
  (egress_pool_proxy), а не первый: первый оказался «живым трупом» (ssh
  держит связь, данные не идут), и загрузка висела на подготовке при 31
  рабочем туннеле.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:11:36 +03:00

177 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# yt-grab
Веб-GUI для [yt-dlp](https://github.com/yt-dlp/yt-dlp): вставил ссылку — выбрал качество, кодек и контейнер — скачал.
**Сервер не хранит ничего.** Настройки и история загрузок живут только в `localStorage`
браузера пользователя и никогда не уходят на сервер. Скачанный файл удаляется с диска
вскоре после того, как пользователь его забрал.
## Возможности
- Превью ролика перед скачиванием: обложка, название, автор, длительность.
- Два режима выбора формата:
- **простой** — качество (до 2160p), контейнер MP4/WebM, аудио в MP3/AAC/Opus;
- **полный** — весь список форматов, которые yt-dlp отдал для конкретной ссылки.
- в простом режиме выбираются видеокодек (H.264 / VP9 / AV1 / любой),
кодек звука (AAC / Opus / любой) и разрешение; для аудио — MP3 / AAC / Opus
либо «Оригинал» без перекодирования.
- Прогресс в реальном времени через Server-Sent Events: проценты, скорость, ETA.
- Отмена загрузки, тёмная и светлая тема, адаптивная вёрстка.
- Страница `/stats` с обезличенными счётчиками нагрузки: сколько запросов,
скачиваний и байт отдано за сегодня / неделю / месяц / всё время.
## Стек
| Слой | Технология | Почему |
|---|---|---|
| Бэкенд | Python 3 + Flask | yt-dlp — Python-библиотека, работаем с ней напрямую, а не через парсинг stdout |
| Прогресс | SSE | Поток односторонний (сервер → браузер); WebSocket избыточен |
| Фронтенд | Один HTML + CSS + vanilla JS | Без сборки и без node_modules; деплой — один процесс |
| Медиа | ffmpeg | Склейка video+audio и перекодирование аудио |
## Установка
Нужны Python 3.11+ и `ffmpeg` в `PATH`.
```bash
git clone <repo> ytdlp-gui && cd ytdlp-gui
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python app.py # http://127.0.0.1:8090
```
## Конфигурация
Всё через переменные окружения (значения ниже сверены с `config.py`):
**Сеть и пути**
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `YTG_HOST` / `YTG_PORT` | `127.0.0.1` / `8090` | Адрес прослушивания |
| `YTG_DOWNLOAD_DIR` | `./downloads` | Временная папка для файлов |
| `YTG_DATA_DIR` | `./data` | Счётчики и лента (`stats.sqlite3`) |
| `YTG_TRUST_PROXY` | `1` | Брать реальный IP из `X-Forwarded-For` |
| `YTG_PROXY` | пусто | Прокси для исходящих запросов yt-dlp. Задаётся **только** администратором: принимать адрес прокси от пользователя нельзя, это SSRF |
| `YTG_YT_REACH_CHECK` | `1` | Сторож прямого пути к YouTube: раз в минуту короткий запрос к `www.youtube.com`. Если путь молчит (блокировка по имени хоста в TLS), YouTube идёт через `YTG_EGRESS_PROXY`, а не висит на «Проверяем…»; состояние — в журнале и в `warnings` у `/readyz` |
Оба каталога перечислены в `ReadWritePaths` юнита. Если переопределяете их
окружением, поправьте и юнит — иначе при `ProtectSystem=strict` сервис упадёт
на первой же записи.
**Нагрузка и пределы**
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `YTG_MAX_CONCURRENT` | `3` | Одновременных загрузок (размер пула воркеров) |
| `YTG_QUEUE_MAX` | `50` | Глубина очереди ожидания; сверх неё — честный 503 |
| `YTG_TASKS_MAX` | `500` | Потолок числа карточек задач в памяти |
| `YTG_CONCURRENT_FRAGMENTS` | `5` | Параллельных фрагментов DASH/HLS. Умножается на число загрузок |
| `YTG_MAX_FILESIZE_MB` | `2048` | Потолок размера одного файла |
| `YTG_MAX_DURATION_SEC` | `14400` | Потолок длительности ролика |
| `YTG_SSE_MAX_SECONDS` | `900` | Предел жизни одного SSE-соединения: оно занимает поток |
| `YTG_RATE_WINDOW_SEC` | `60` | Окно подсчёта обращений |
| `YTG_RATE_MAX_INFO` | `20` | Запросов метаданных на IP за окно |
| `YTG_RATE_MAX_DOWNLOAD` | `6` | Запусков загрузки на IP за окно |
**Диск и сроки хранения**
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `YTG_DISK_QUOTA_MB` | `5120` | Квота на всю папку загрузок. При превышении готовые файлы вытесняются, начиная со старых; отказ — только если квоту занимают идущие загрузки |
| `YTG_EVICT_MIN_AGE_SEC` | `300` | Файл моложе этого срока вытесняется по квоте в последнюю очередь (предпочтение, не запрет) |
| `YTG_MIN_FREE_DISK_MB` | `10240` | **Главный предохранитель**: при меньшем свободном месте на разделе загрузки не принимаются и прерываются на ходу |
| `YTG_DELETE_AFTER_SERVE` | `1` | Удалять файл после отдачи |
| `YTG_SERVED_GRACE_SEC` | `1800` | Отсрочка удаления после выдачи (запас на докачку) |
| `YTG_FILE_TTL_MINUTES` | `120` | Жёсткий потолок жизни файла, от момента готовности |
| `YTG_TASK_TTL_MINUTES` | `240` | Срок карточки задачи. Держите больше `FILE_TTL`: карточка считается от создания задачи, а файл — от окончания скачивания |
| `YTG_JANITOR_INTERVAL_SEC` | `30` | Период работы уборщика |
**Приватность и лента**
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `YTG_QUIET_ACCESS_LOG` | `1` | Не писать обращения в лог |
| `YTG_FEED_ENABLED` | `1` | Публичная лента «что скачивают». `0` полностью отключает запись ссылок |
| `YTG_FEED_PAGE_SIZE` | `50` | Размер страницы ленты |
## Продакшен
```bash
sudo cp deploy/ytdlp-gui.service /etc/systemd/system/
sudo systemctl enable --now ytdlp-gui
sudo cp deploy/nginx-yt.zapret.moe.conf /etc/nginx/sites-available/yt.zapret.moe
sudo ln -s ../sites-available/yt.zapret.moe /etc/nginx/sites-enabled/
sudo certbot certonly --webroot -w /var/www/letsencrypt -d yt.zapret.moe
sudo nginx -t && sudo systemctl reload nginx
```
Запускается **одним** воркером gunicorn с несколькими потоками: состояние задач
держится в памяти процесса, поэтому несколько воркеров его разорвут.
Важные детали nginx-конфига: для SSE-эндпоинта выключена буферизация
(`proxy_buffering off`), иначе прогресс копится в буфере и не доходит до браузера;
для отдачи файла — `proxy_max_temp_file_size 0`, чтобы многогигабайтные ответы
не складывались во временные файлы.
## Безопасность
Сервис публичный, поэтому пользовательский ввод жёстко ограничен:
- Принимаются только схемы `http`/`https`, а имя хоста резолвится и проверяется:
приватные, loopback- и link-local адреса отклоняются. Без этого генерик-экстрактор
yt-dlp ходил бы по внутренней сети и на `169.254.169.254` — то есть SSRF.
- Опции yt-dlp **не** собираются из пользовательской строки. Простой режим строится из
фиксированного перечня, а в полном режиме `format_id` проходит через строгий regex
`^[A-Za-z0-9_\-.]+(\+[A-Za-z0-9_\-.]+)?$` — он пропускает идентификаторы и связку
`137+140`, но не синтаксис селекторов (скобки, фильтры, `/`, пробелы).
- Выходная директория фиксирована; отдача файла — только по basename с проверкой
`realpath` против path traversal.
- Лимиты: число одновременных загрузок, размер файла, длительность ролика,
квота на диск, rate limit по IP (в приложении и в nginx).
- Доступ пользователя к `outtmpl`, `paths`, `exec`, `postprocessor_args` и
`cookiesfrombrowser` не предоставляется ни в каком виде.
## Приватность
- URL и IP пользователей не пишутся в логи (`noprogress`, `--access-logfile /dev/null`).
- История и настройки — только `localStorage` в браузере.
- Файлы удаляются с сервера после выдачи; незабранные — по TTL.
На диск сохраняются только два вида записей, оба в `data/stats.sqlite3`:
- **Обезличенные суточные счётчики** — количество запросов, загрузок и сумма
отданных байт. Ни IP, ни User-Agent.
- **Лента скачанного** — адрес ролика, дата и счётчик. Это публикуется на
`/stats`. Ни названия, ни обложки, ни размера здесь нет: их показывает
браузер посетителя, забирая напрямую с источника.
Связи между этими записями и конкретным человеком не хранится. Но адрес ролика
— это всё-таки данные: не вставляйте в сервис приватные ссылки с токенами,
они попадут в публичную ленту. Ленту можно выключить через `YTG_FEED_ENABLED=0`.
## Заметки о поведении yt-dlp
Неочевидные вещи, на которые ушло время и которые закреплены в коде:
- **Фильтры форматов регистрозависимы.** `[vcodec^=avc1]` не находит `AVC1.640028`,
поэтому кодеки сравниваются регистронезависимым regex. При этом `(?i)` в начале
выражения падает с `PatternError` — допустима только группа `(?i:...)`.
- **VP9 приходит под двумя именами:** `vp9` (HLS) и `vp09.xx` (DASH), а `^=vp`
зацепил бы ещё и VP8. Отсюда `~='(?i:^vp0?9)'`.
- **Значение фильтра нужно кавычить,** если в нём есть что-то кроме `[\w.-]`.
- **`[height<=1080]` молча выбрасывает форматы с неизвестной высотой** — нужен
`?` после оператора: `[height<=?1080]`.
- **`format_sort` в долгоживущем процессе опасен:** `FormatSorter.settings` —
атрибут класса, и некорректный лимит портит сортировку на весь процесс gunicorn.
Поэтому он здесь не используется.
- **`send_file` + `Response.call_on_close` не работает:** включается
`direct_passthrough`, и WSGI закрывает файловую обёртку, а не `Response`,
так что колбэк не вызывается. Удаление файла сделано через уборщик по отметке времени.
- **`restrictfilenames` вырезает всю кириллицу**, из-за чего имя файла может
выродиться в `..`. Имя на диске задаётся из id задачи, а красивое уходит
пользователю через `download_name`.
## Лицензия
MIT. Скачивайте только тот контент, на который у вас есть права.