- Python 63.6%
- HTML 25.9%
- CSS 10.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Пагинация ленты. Листание шло через OFFSET, а last_seen меняется при
каждом скачивании: запись, обновлённая между запросами страниц, сдвигала
выборку, и одни записи попадали на две страницы, а другие не попадали ни
на одну. Теперь листание по ключу сортировки через непрозрачный курсор.
Одиночный индекс по count не покрывал вторую часть сортировки, поэтому
для «популярных» заведён составной.
Сроки хранения. Таблицы ленты и суточных счётчиков росли бессрочно, а
разовые миграции перечитывали всю ленту в память при КАЖДОМ старте —
время запуска росло вместе с таблицей. Добавлены сроки хранения и отметка
о выполненных миграциях: теперь они отрабатывают один раз.
Канонизация ссылок теряла порт, схему и якорь. Из-за этого ролики с
разных портов схлопывались в одну запись ленты, http-only сайт получал
нерабочую https-ссылку, а одностраничные приложения, где идентификатор
живёт в якоре, сливались все вместе. IPv6-литерал вдобавок терял скобки и
становился неразбираемым.
Плагин pmvhaven — два дефекта, оба с последствиями для безопасности:
* Бралась ПЕРВАЯ подходящая ссылка со страницы без проверки адреса.
Рекламной вставки или пользовательского текста хватало, чтобы увести
загрузку на чужой адрес, в том числе внутренний: проверка адреса в
приложении покрывает только ссылку ОТ пользователя, а не то, что
выковыряно со страницы. Теперь кандидаты ранжируются, хост проверяется
на публичность, плейлисты предпочитаются готовым файлам.
* Шаблон исключал обратный слэш, поэтому экранированную форму
(`https:\/\/...`) не находил вовсе, а строка с обратной заменой была
недостижимым кодом. Вдобавок нежадный квантификатор обрывал ссылку на
первом расширении, а в путях CDN оно встречается в середине
(.../x.mp4/master.m3u8). Расширение проверяется после разэкранирования.
Добавлена страница исходников на GitHub как зеркало, знак взят из набора
Simple Icons под CC0 (сам знак остаётся товарным знаком GitHub и стоит
здесь только как ссылка на репозиторий). Заголовок секции с переключателем
рядом больше не распирает узкий экран.
Тестов 224: пагинация проверяется на том самом сценарии со сдвигом записи
между страницами, плагин — на экранированных ссылках и приватных адресах.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwY2Cg54RK7WZdvMaLV5Di
|
||
| deploy | ||
| downloads | ||
| static | ||
| templates | ||
| tests | ||
| yt_dlp_plugins | ||
| .gitignore | ||
| app.py | ||
| config.py | ||
| downloader.py | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
| stats.py | ||
yt-grab
Веб-GUI для 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.
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 |
Размер страницы ленты |
Продакшен
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. Скачивайте только тот контент, на который у вас есть права.