yt-dlp-gui/README.md
codex-pve f1e4cdbe1d Запрет исходящих во внутреннюю сеть заработал: убран IPAddressAllow=any
Главное. Строка IPAddressAllow=any обесценивала весь список запретов ниже,
и заявленная защита от SSRF существовала только на бумаге. Из cgroup
сервиса оставались доступны Proxmox API на 10.20.0.1:8006 и соседняя
виртуальная машина — проверено установлением соединения. Сбивало с толку
то, что адрес метаданных 169.254.169.254 при этом блокировался, поэтому
прежняя проверка выглядела успешной.

Когда список состоит только из запретов, фильтр работает: внутренняя сеть
закрыта, внешняя и DNS продолжают работать. Всё проверено отдельными
запусками через systemd-run до применения на боевом юните.

Петля не закрывается сознательно. Фильтр systemd не различает направление,
и запрет 127.0.0.0/8 обрывает ВХОДЯЩИЕ соединения nginx к этому же
сервису — проверено: слушатель внутри такой cgroup становится недоступен
снаружи, то есть сайт лёг бы. Службы на 127.0.0.1 остаются достижимы;
закрыть их можно, только переведя связку nginx→приложение на unix-сокет.

Дисковый бюджет. Юнит вчетверо поднимал квоту относительно кода (20 ГБ
против 5 ГБ) при 35 ГБ свободного на разделе гипервизора, где живут
виртуальные машины. Приведено к 5 ГБ.

/readyz больше не витрина. Наружу уходит только признак готовности;
свободное место, глубина очереди и версия yt-dlp отдаются лишь локальному
мониторингу — сайт публичный.

Статика кэшируется на месяц: стили и восемь файлов шрифта отдаются этим же
процессом, а потоки здесь дефицитны из-за SSE, и браузер ревалидировал все
девять файлов на каждой навигации.

Согласованы сроки хранения: карточка задачи считается от создания, а файл —
от окончания скачивания, поэтому при равных TTL карточка истекала раньше
файла, и файл оставался недостижимым, занимая квоту. Срок карточки поднят
до 240 минут против 120 у файла.

Также: .gitkeep больше не удаляется уборщиком по TTL (при подметании на
старте он уже исключался), README приведён в соответствие с config.py —
три значения по умолчанию были указаны неверно, тринадцать переменных не
описаны вовсе.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwY2Cg54RK7WZdvMaLV5Di
2026-09-21 18:19:04 +03:00

175 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 |
Оба каталога перечислены в `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_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. Скачивайте только тот контент, на который у вас есть права.