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>
168 lines
14 KiB
Python
168 lines
14 KiB
Python
"""Конфигурация сервиса. Значения переопределяются переменными окружения."""
|
||
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"))
|