yt-dlp-gui/config.py
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

168 lines
14 KiB
Python
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.

"""Конфигурация сервиса. Значения переопределяются переменными окружения."""
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
DOWNLOAD_DIR = Path(os.environ.get("YTG_DOWNLOAD_DIR", BASE_DIR / "downloads"))
# Каталог для служебных данных (обезличенные счётчики). Вынесен отдельно,
# потому что systemd монтирует остальной проект только на чтение.
DATA_DIR = Path(os.environ.get("YTG_DATA_DIR", BASE_DIR / "data"))
# Сеть
HOST = os.environ.get("YTG_HOST", "127.0.0.1")
PORT = int(os.environ.get("YTG_PORT", "8090"))
# Публичный сервис -> защита от абуза
MAX_CONCURRENT_DOWNLOADS = int(os.environ.get("YTG_MAX_CONCURRENT", "3"))
# Глубина очереди ожидания и потолок на число карточек задач в памяти.
# Оба — защита от переполнения: переполнить память или число потоков
# не должно получаться в принципе.
QUEUE_MAX = int(os.environ.get("YTG_QUEUE_MAX", "50"))
TASKS_MAX = int(os.environ.get("YTG_TASKS_MAX", "500"))
# Параллельная загрузка фрагментов (DASH/HLS — то, чем отдаёт YouTube).
# Без этого фрагменты тянутся строго по одному, и скорость упирается в
# задержку до сервера, а не в канал. Держим умеренным: значение умножается
# на число одновременных загрузок, а хост — гипервизор.
# YouTube троттлит каждое соединение, поэтому скорость даёт параллельность.
# 8 — быстро; устойчивость к редким TLS-таймаутам обеспечивают ретраи ниже,
# а не снижение параллельности (при 4 скорость падала вдвое).
CONCURRENT_FRAGMENTS = int(os.environ.get("YTG_CONCURRENT_FRAGMENTS", "8"))
# Каждая загрузка идёт в отдельном процессе (jobrunner). Пределы его сторожа:
# сколько ждать прогресса, прежде чем счесть источник зависшим (ретраи
# yt-dlp при этом уже исчерпаны или бесполезны); сколько может длиться
# склейка/перекодирование без событий; сколько ждать мягкой остановки,
# прежде чем добить процесс вместе с ffmpeg.
STALL_SEC = int(os.environ.get("YTG_STALL_SEC", "180"))
PROCESSING_TIMEOUT_SEC = int(os.environ.get("YTG_PROCESSING_TIMEOUT_SEC", "3600"))
CANCEL_GRACE_SEC = int(os.environ.get("YTG_CANCEL_GRACE_SEC", "15"))
# Файлы с googlevideo качать своим параллельным загрузчиком (racefd):
# DPI пропускает лишь часть соединений, и много коротких попыток находят
# живые за секунды. Выключатель — на случай, если он начнёт мешать.
YT_PARALLEL = os.environ.get("YTG_YT_PARALLEL", "1") == "1"
# Общий список заблокированных видеосерверов (hostban): сервер, к которому
# подряд BAN_FAILS соединений не прошло ни одно, все загрузки обходят
# BAN_TTL_SEC секунд (блокировки со временем меняются). Удача снимает бан.
BAN_FAILS = int(os.environ.get("YTG_BAN_FAILS", "8"))
BAN_TTL_SEC = int(os.environ.get("YTG_BAN_TTL_SEC", "900"))
def _parse_pool(raw: str) -> list[str]:
"""Пул прокси: через запятую, либо диапазон портов
'socks5h://127.0.0.1:18110-18125'."""
import re
out = []
for part in (x.strip() for x in raw.split(",")):
m = re.fullmatch(r"(socks5h?://[^/:]+:)(\d+)-(\d+)", part)
if m:
out += [f"{m.group(1)}{p}" for p in range(int(m.group(2)), int(m.group(3)) + 1)]
elif part:
out.append(part)
return out
# Отдельный IP для YouTube (выдан хостером): прямые соединения к YouTube идут
# с него. На основном IP YouTube включил антибот, а новому выдаёт другие,
# незаблокированные видеосерверы. Адрес поднимает ytgrab-youtube-ip.service.
# Пусто — YouTube идёт с основного IP.
YT_SOURCE_IP = os.environ.get("YTG_YT_SOURCE_IP", "").strip()
# Пул SOCKS-туннелей до egress-узла для YouTube, когда все прямые видеосерверы
# ролика заблокированы (racefd). Задаёт только администратор. Пусто — выкл.
EGRESS_POOL = _parse_pool(os.environ.get("YTG_EGRESS_POOL", ""))
# Прокси для исходящих запросов yt-dlp (socks5://host:port или http://...).
# Задаётся ТОЛЬКО администратором через окружение и никогда не принимается
# от пользователя: произвольный прокси — это готовый SSRF.
# Нужен там, где сайт недоступен напрямую из сети хоста.
PROXY = os.environ.get("YTG_PROXY", "").strip()
# Прокси-«запаска» ТОЛЬКО для забаненного напрямую контента (IP-блок, гео,
# 403). В отличие от PROXY гоняется не всегда, а лишь когда прямой доступ
# упёрся в бан — тогда probe пробует ещё раз через него, и если помогло,
# скачивание идёт тоже через него. Пусто -> механизм выключен.
EGRESS_PROXY = os.environ.get("YTG_EGRESS_PROXY", "").strip()
# Короткая проверка «YouTube напрямую отвечает?» (downloader.yt_direct_ok).
# Провайдер умеет молча глотать TLS с именем youtube.com: ошибки нет, yt-dlp
# ждёт по 30 с на запрос, и «Проверяем…» висит минутами (13.10.2026 — 16
# часов подряд). Если проверка не прошла, YouTube сразу идёт через
# EGRESS_PROXY. Без EGRESS_PROXY только пишет в журнал и в /readyz.
YT_REACH_CHECK = os.environ.get("YTG_YT_REACH_CHECK", "1") == "1"
MAX_FILESIZE_MB = int(os.environ.get("YTG_MAX_FILESIZE_MB", "2048")) # потолок на один файл
# Отрезок с YouTube качается «по запросу» — только нужные байты (racefd.
# SectionSession). Но HLS-дорожки так не умеют: тогда ролик качается ЦЕЛИКОМ
# и режется потом (jobrunner.trim_file) — для этого отката свой потолок на
# целый ролик. Результат — по-прежнему не больше MAX_FILESIZE_MB.
CLIP_SOURCE_MAX_MB = int(os.environ.get("YTG_CLIP_SOURCE_MAX_MB", "4096"))
# Плейлист архивом: не больше стольких роликов за раз (архив целиком — в
# пределах MAX_FILESIZE_MB). Больше — и разборы подряд грозят антиботом YouTube.
PLAYLIST_DL_MAX = int(os.environ.get("YTG_PLAYLIST_DL_MAX", "50"))
MAX_DURATION_SEC = int(os.environ.get("YTG_MAX_DURATION_SEC", str(4 * 3600))) # 4 часа
DISK_QUOTA_MB = int(os.environ.get("YTG_DISK_QUOTA_MB", "5120")) # 5 ГБ на всю папку
# Сервис живёт на Proxmox-хосте: мало оставить место себе, нужно не съесть
# его у гипервизора. Если на разделе свободно меньше — новые загрузки не
# принимаются, независимо от квоты выше.
MIN_FREE_DISK_MB = int(os.environ.get("YTG_MIN_FREE_DISK_MB", "10240")) # 10 ГБ
# Когда папка упирается в квоту, готовые файлы вытесняются, начиная со
# старых (downloader._evict_for_quota). Файл моложе этого срока уходит в
# последнюю очередь: человеку нужно время нажать «Сохранить» и докачать.
# Это предпочтение, а не запрет — свежие тоже удаляются, если иначе новая
# загрузка получила бы отказ.
EVICT_MIN_AGE_SEC = int(os.environ.get("YTG_EVICT_MIN_AGE_SEC", "300"))
# --- Приватность: сервер не хранит ничего ---
# Файл удаляется после того, как пользователь его забрал.
DELETE_AFTER_SERVE = os.environ.get("YTG_DELETE_AFTER_SERVE", "1") == "1"
# Отсрочка после начала выдачи: запас на докачку по Range и повторный клик.
SERVED_GRACE_SEC = int(os.environ.get("YTG_SERVED_GRACE_SEC", "1800"))
# Как часто работает уборщик.
JANITOR_INTERVAL_SEC = int(os.environ.get("YTG_JANITOR_INTERVAL_SEC", "30"))
# Жёсткий потолок: ни один файл не живёт на сервере дольше этого,
# независимо от того, забрали его или нет. Отсчёт — от готовности файла.
# 2 часа: за это время файл в 2 ГБ (наш потолок) успевает скачаться даже
# на 2,3 Мбит/с. Общий объём ограничивает не время, а квота DISK_QUOTA_MB.
FILE_TTL_MINUTES = int(os.environ.get("YTG_FILE_TTL_MINUTES", "120"))
# Карточки задач держатся в памяти недолго и нигде не персистятся.
# Срок заведомо больше FILE_TTL_MINUTES: карточка отсчитывается от создания
# задачи, а файл — от окончания скачивания, поэтому при равных значениях
# карточка истекала раньше файла ровно на длительность загрузки, и файл
# оставался на диске недостижимым, занимая квоту.
TASK_TTL_MINUTES = int(os.environ.get("YTG_TASK_TTL_MINUTES", "240"))
# Не писать URL/IP пользователей в логи.
QUIET_ACCESS_LOG = os.environ.get("YTG_QUIET_ACCESS_LOG", "1") == "1"
# Rate limiting (простое, in-memory, на IP)
RATE_WINDOW_SEC = int(os.environ.get("YTG_RATE_WINDOW_SEC", "60"))
RATE_MAX_INFO = int(os.environ.get("YTG_RATE_MAX_INFO", "20")) # запросов /api/info за окно
RATE_MAX_DOWNLOAD = int(os.environ.get("YTG_RATE_MAX_DOWNLOAD", "6")) # запусков загрузки за окно
# Заголовки, которым доверяем реальный IP (за reverse-proxy)
TRUST_PROXY = os.environ.get("YTG_TRUST_PROXY", "1") == "1"
# Максимальный срок одного SSE-соединения. Каждое занимает поток gunicorn,
# поэтому рвём его принудительно — EventSource переподключится сам.
SSE_MAX_SECONDS = int(os.environ.get("YTG_SSE_MAX_SECONDS", "900"))
# --- Публичная лента «что скачивают» ---
# Храним ТОЛЬКО адрес ролика, дату и счётчик. Ни названия, ни обложки:
# их подтягивает браузер напрямую с источника.
FEED_ENABLED = os.environ.get("YTG_FEED_ENABLED", "1") == "1"
FEED_PAGE_SIZE = int(os.environ.get("YTG_FEED_PAGE_SIZE", "50"))
# Сколько хранить времена отдельных скачиваний. Таблица растёт на строку с
# каждой загрузкой, и без предела превращается в вечный журнал активности.
# Одна запись — около 190 байт с индексами, так что год при ~1000 загрузках
# в сутки — это ~70 МБ. Держим наравне с лентой (FEED_RETENTION_DAYS).
EVENT_RETENTION_DAYS = int(os.environ.get("YTG_EVENT_RETENTION_DAYS", "365"))
# Через сколько считать задачу зависшей и снимать её. Отдельная настройка,
# а не множитель от TASK_TTL: прежняя запись `TASK_TTL_MINUTES * 120`
# читалась как путаница минут с секундами, хотя означала «два TTL».
STUCK_TASK_SEC = int(os.environ.get("YTG_STUCK_TASK_SEC", str(8 * 3600)))
# Сроки хранения. Обе таблицы росли бессрочно, а ленту вдобавок целиком
# перечитывала миграция при каждом старте. 0 отключает очистку.
FEED_RETENTION_DAYS = int(os.environ.get("YTG_FEED_RETENTION_DAYS", "365"))
DAILY_RETENTION_DAYS = int(os.environ.get("YTG_DAILY_RETENTION_DAYS", "730"))
# Метрики производительности загрузок (тайминги, скорость, смена серверов).
# Нужны, чтобы видеть, как часто загрузка упирается в DPI; месяца хватает.
PERF_RETENTION_DAYS = int(os.environ.get("YTG_PERF_RETENTION_DAYS", "30"))