Главное. Строка 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
175 lines
13 KiB
Markdown
175 lines
13 KiB
Markdown
# 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. Скачивайте только тот контент, на который у вас есть права.
|