yt-dlp-gui/AGENTS.md
loop-uh a25fc3d666 Режим «Для старого телевизора»: перекодирование в MKV H.264 High@4.0 ≤720p + AAC 48 кГц
Проверено на Philips 24PFL4228T: потоки YouTube как есть не играют (ни видео,
ни звук 44,1 кГц), перекодированные — играют; 70-минутный MP4 не открылся
(индекс ~3 МБ), 70-минутный MKV открылся. Галочка в настройках видео, потолок
качества 720p, битрейт ограничен лимитом размера файла.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 19:00:30 +03:00

39 KiB
Raw Permalink Blame History

AGENTS.md — карта развёртывания и инфраструктуры

Заметки для будущих агентов: что где стоит на боевом хосте. Код живёт в этом репозитории, но прод и обход блокировок настроены снаружи — здесь их адреса.

Адреса здесь — заглушки. Репозиторий публичный (Forgejo и зеркало на GitHub), а настоящие адреса хоста выдают, где стоит yt.zapret.moe. Значения <ОСНОВНОЙ_IP_ХОСТА>, <IP_ДЛЯ_YOUTUBE>, <IP_AI_EGRESS> — только в закрытом ~/pve-admin/servers.md на хосте; реальные юниты — в /etc/systemd/system/. В этот репозиторий адреса не записывать (решение владельца 2026-10-17).

Боевой сервис

  • Домен: yt.zapret.moe → nginx → http://127.0.0.1:8090.
  • systemd-юнит: /etc/systemd/system/ytdlp-gui.service (ytdlp-gui.service).
    • WorkingDirectory=/home/codex-pve/ytdlp-gui — прод крутится из этого репозитория. Любой git-коммит в файлы приложения попадёт в прод при следующем systemctl restart ytdlp-gui.
    • gunicorn: --workers 1 --threads 64 (один процесс: состояние задач держится в памяти, поэтому рестарт стирает очередь и текущие загрузки).
    • Перезапуск: sudo systemctl restart ytdlp-gui.
    • Работает от системного пользователя ytgrab, не от codex-pve. На хосте стоит VPN-замок ccw-vpn (ccw-vpn-lock.service, /etc/ccw-vpn): весь трафик uid 1000 (codex-pve) идёт в туннель hytun, а мимо него — сброс. Под codex-pve привязка к <IP_ДЛЯ_YOUTUBE> давала «Connection reset by peer» на любой запрос к YouTube (29.09.2026). Доступ ytgrab к репо — ACL: x на /home/codex-pve, rX на репо, rwX на downloads/ и data/ (с default-ACL для новых файлов). Проверять связь — от его имени: sudo -u ytgrab curl --interface <IP_ДЛЯ_YOUTUBE> https://ipinfo.io/ip; curl от codex-pve покажет выход VPN, а не прод.
  • nginx: /etc/nginx/sites-available/yt.zapret.moe. Лимиты: limit_conn 24 на IP, limit_req 30r/m (heavy) и 240r/m (read).

Ключевые параметры (в юните, через Environment=)

Переменная Значение Смысл
YTG_MAX_CONCURRENT 32 одновременных загрузок (воркер-потоков)
YTG_QUEUE_MAX 50 (дефолт) сверх 32 ждут в очереди, потом 503
YTG_TASKS_MAX 500 (дефолт) потолок записей задач
YTG_MAX_FILESIZE_MB 2048 максимум на файл
YTG_CLIP_SOURCE_MAX_MB 4096 (дефолт) только откат отрезка YouTube (HLS-дорожки): ролик качается целиком — потолок на целый; результат ≤ MAX_FILESIZE_MB
YTG_DISK_QUOTA_MB 51200 квота на папку загрузок (50 ГБ, с 13.10.2026; было 5 ГБ — в неё влезало два с половиной файла предельного размера); при превышении старые готовые файлы вытесняются (_evict_for_quota), а не дают отказ
YTG_EVICT_MIN_AGE_SEC 300 (дефолт) файл моложе вытесняется в последнюю очередь
MemoryMax / TasksMax 4G / 2048 лимиты cgroup (юнит); процессы загрузок считаются сюда же

Хост — гипервизор Proxmox с ВМ. Юнит намеренно ограничивает ресурсы (CPUWeight=20, IOWeight=20, Nice=10) и запрещает исходящие во внутреннюю сеть (IPAddressDeny на 10/8, 172.16/12, 192.168/16 и т.д.) — не снимать.

Как это работает внутри

  • Каждая загрузка — отдельный процесс python -m jobrunner (в ps их видно). gunicorn держит только очередь и состояние задач (downloader.DownloadManager), а исполнитель получает задачу JSON'ом в stdin и шлёт события JSON-строками в stdout (preparing → downloading → processing → done). Так 32 загрузки не делят один GIL и одну кучу (раньше пик памяти упирался в MemoryMax и шёл своп).
  • Сторож в менеджере: нет прогресса YTG_STALL_SEC (180 с) — задача снимается с понятной ошибкой; отмена — SIGTERM, через YTG_CANCEL_GRACE_SEC — SIGKILL всей группы процессов (вместе с ffmpeg). Склейке/MP3 даётся YTG_PROCESSING_TIMEOUT_SEC.
  • Разбор ссылки (probe) по-прежнему в процессе gunicorn — он короткий. Его результат кэшируется в памяти (downloader.cache_info, 10 мин, ≤50 записей, ~130 КБ на ролик без автосубтитров) и отдаётся исполнителю загрузки — тот не разбирает ролик заново (−1…1,5 с, и вдвое меньше запросов к YouTube). Кэш берётся только при том же пути (напрямую/egress): ссылки привязаны к IP.
  • Один файл на задачу: имя на диске <task_id>.<ext>, отдача через /api/file.
  • Здоровье и метрики (только с localhost): curl -s http://127.0.0.1:8090/readyz → queue_pending, tasks_active, downloads_mb, free_disk_mb, youtube_direct (ok, с какого времени, давность проверки). /healthz — грубый «жив». Поле warnings — деградации, при которых сервис всё же работает; ["youtube_direct"] = YouTube напрямую не отвечает (ниже).
  • Сторож прямого пути к YouTube (downloader.yt_direct_ok, 13.10.2026): раз в минуту (из уборщика) короткий запрос HEAD /generate_204 к www.youtube.com с YTG_YT_SOURCE_IP, таймаут 4 с, отказ — две неудачи подряд. Пока путь молчит, разбор ссылок и списков YouTube и сами загрузки сразу идут через egress (yt_egress_fallback; путь выбирает сервер, а не признак от клиента), перепроверка — раз в 20 с. В журнале по строке на смену состояния: journalctl -u ytdlp-gui | grep '\[youtube\]'. /readyz при этом остаётся ok (остальные сайты работают, YouTube — медленнее, через пул) и несёт warnings. Выключатель: YTG_YT_REACH_CHECK=0. Оповещений наружу нет: /readyz никто не опрашивает.

Обход блокировок (DPI по SNI)

Провайдерский DPI (ТСПУ) рвёт TLS-хендшейк по имени хоста в SNI — из-за этого soundcloud.com, x.com и др. не открывались (_ssl.c:1012 handshake timed out). Диагностика: openssl s_client -connect <ip>:443 -servername <host> — если конкретный SNI даёт Terminated, а другой на том же IP CONNECTED, это SNI-DPI, не блокировка IP.

  • Инструмент: zapret2 (nfqws2), сервис zapret-egress.service, каталог /opt/zapret-egress. Документация: github.com/bol-van/zapret2.
  • Механизм: nftables (nft-apply.sh, таблица zapret_egress) заворачивает первые пакеты исходящих TCP/443-потоков в nfqueue 200; nfqws2 применяет десинхронизацию TLS ClientHello (multidisorder) только для хостов из списка.
  • Список хостов: /opt/zapret-egress/egress-hosts.txt (root). Матчатся хост и все поддомены. Чтобы разблокировать новый сайт — дописать его домен сюда и sudo systemctl restart zapret-egress.
    • Уже добавлены: soundcloud.com, sndcdn.com, twimg.com, instagram.com, cdninstagram.com, fbcdn.net, pornhub.com, phncdn.com (плюс исходные t.me, vkvideo.ru, xvideos.com и т.д.).
  • У X свой список и своя стратегия (01.10.2026): /opt/zapret-egress/egress-hosts-x.txt (x.com, twitter.com, t.co) — первый профиль в юните, hostfakesplit:tcp_ts=-1000:repeats=1; общий список — второй профиль (--new), прежний multidisorder. Не возвращать эти домены в общий список. x.com/api.x.com живут на двух адресах Cloudflare, DNS отдаёт их по очереди: на 172.66.0.227 всё открывается и без обхода, а на 162.159.140.229 провайдер пропускает ClientHello и глушит поток уже ПОСЛЕ рукопожатия (0 байт ответа), multidisorder там не проходит даже рукопожатие. Отсюда «Проверяем…» и ошибка через раз: SSL: UNEXPECTED_EOF_WHILE_READING, RemoteDisconnected, Read timed out на «Downloading guest token / GraphQL JSON». Стратегия опирается на TCP timestamps (net.ipv4.tcp_timestamps=1): подложный сегмент сервер отбрасывает по PAWS.
  • У YouTube тоже свой список и своя стратегия (13.10.2026): /opt/zapret-egress/egress-hosts-yt.txt (youtube.com, youtu.be, ytimg.com, ggpht.com, youtubei.googleapis.com) — второй профиль в юните, hostfakesplit_multi:hosts=google.com,vimeo.com:tcp_ts=-1000:tcp_md5:repeats=2 (стратегию выбрал владелец). До этого дня YouTube в списках не было вовсе: провайдер его SNI не трогал, обходили только googlevideo.com. 13.10.2026 он начал молча глотать ClientHello с этими именами — TCP к 142.251.15x.4 проходит за 0,02 с, с SNI www.google.com на том же адресе TLS поднимается, с www.youtube.com молчит. На сайте это выглядело как вечное «Проверяем…» при пустом журнале: yt-dlp ждёт по 30 с на запрос и повторяет, а запасной путь через egress в probe включался только по тексту бана (_BAN_SIGNATURES), таймаут туда не входит. Не режут www.google.com, accounts.google.com, www.googleapis.com. По таблице perf YouTube не работал около 16 часов (последняя удачная загрузка — в третьем часу ночи, починено в 18:55), и ничто об этом не сообщило: /readyz оставался ok. С того же дня за этим следит сторож прямого пути (см. «Как это работает внутри»): строка [youtube] напрямую не отвечает в журнале = пора подбирать стратегию, сайт тем временем работает через egress. Быстрая проверка: sudo -u ytgrab curl -m 8 --interface <IP_ДЛЯ_YOUTUBE> https://www.youtube.com/ -o /dev/null -w '%{http_code}/%{size_download}\n' → 200/95xxxx; 000/0 при живом https://www.google.com/ = снова SNI.
    • hostfakesplit_multi — не штатная функция zapret2, а из /opt/zapret-egress/lua/zapret-multishake.lua (подключён третьим --lua-init; исходник — zapretgui/private_zapretgui/dist/lua/). При обновлении zapret2 файл надо сохранить. nfqws2 запускает Lua уже со сброшенными правами (uid 1): файл должен читаться всем, вместе с каталогами на пути.
    • Запас, если multishake сломается: на стенде в тот же день YouTube пробивали 31 стратегия из 62 — весь multidisorder (включая общую pos=1,midsld, то есть хватило бы и просто дописать домены в общий список), весь hostfakesplit, fake с tcp_ts/tcp_md5; простой multisplit — нет.
  • Проверять полным HTTP-запросом, а не рукопожатием. openssl s_client … CONNECTED и удачный TLS-хендшейк здесь ничего не доказывают (см. выше): нужен ответ с телом, на КАЖДОМ адресе хоста, несколько раз подряд — sudo -u ytgrab curl --resolve x.com:443:162.159.140.229 https://x.com/ -o /dev/null -w '%{http_code}/%{size_download}\n' → 200/35xxx; затем yt-dlp --simulate <url> раз десять (один успех — не показатель: мог выпасть «хороший» адрес). Проверять от ytgrab или root: трафик codex-pve уходит в VPN.
  • Стратегия перестала работать — подобрать новую, не трогая прод: sudo python3 /opt/zapret-egress/strattest.py /opt/zapret-egress/strattest-strategies.txt <ip> <sni> / <мин. байт ответа> <повторов>. Стенд поднимает второй nfqws2 (очередь 201) и отдельную таблицу nft, которая ловит только сокеты с меткой 0x00100000; боевая очередь 200 не затрагивается, таблица убирается сама. 01.10.2026 из 55 стратегий адрес 162.159.140.229 пробивали 10: hostfakesplit (с tcp_md5/tcp_ts), fake с tcp_ts=-1000 или tcp_seq=1000000, multisplit:pos=10,midsld:seqovl=1; весь multidisorder и простой multisplit — нет.
  • Проверка после добавления сайта в общий список: полный запрос (как выше), затем yt-dlp --simulate <url>.

YouTube: отдельный IP <IP_ДЛЯ_YOUTUBE> (главное)

Хостер (SERV.HOST, после обращения 22.09.2026) выдал дополнительный IP <IP_ДЛЯ_YOUTUBE> «для доступа к YouTube». Весь прямой трафик к YouTube (разбор в probe, загрузка в jobrunner, racefd и его повторные разборы) идёт с него (YTG_YT_SOURCE_IP в юните сервиса; пусто — выкл). Остальные сайты — с основного <ОСНОВНОЙ_IP_ХОСТА>.

  • На основном IP YouTube держит антибот («not a bot»), на новом — нет.
  • Новому IP YouTube выдаёт другие кластеры видеосерверов (напр. sn-n8v7znz7), которые провайдер не режет; 8 соединений кусками — ~100 МБ/с. Серверы, что провайдер режет по адресу назначения, заблокированы и с нового IP — обходы ниже (racefd, hostban, egress-пул) остаются страховкой.
  • Адрес на хосте: ytgrab-youtube-ip.service (ip addr replace <IP_ДЛЯ_YOUTUBE>/32 dev vmbr0, маршруты и мосты не трогает) + ytgrab-youtube-ip-check.timer раз в 5 мин возвращает адрес, если его сняла перезагрузка сети Proxmox (ifreload убирает адреса, которых нет в /etc/network/interfaces — сам interfaces намеренно не правили: ошибка там оставит гипервизор без сети).
  • Проверка: curl --interface <IP_ДЛЯ_YOUTUBE> https://ipinfo.io/ip → <IP_ДЛЯ_YOUTUBE>; yt-dlp --source-address <IP_ДЛЯ_YOUTUBE> --simulate <ролик> → без «not a bot».

YouTube: замедление googlevideo и свой загрузчик (racefd.py)

Сами ролики YouTube отдают серверы rr*---sn-*.googlevideo.com. Провайдерский DPI к ним пропускает пачку новых TLS-соединений после паузы, а следующие вешает на рукопожатии (TCP проходит, TLS молчит; с любым SNI). Установленное соединение работает в полную скорость. Отсюда «висит на подготовке», а потом ~1 МБ/с: штатный загрузчик yt-dlp ждёт по 20 с на каждое новое соединение.

  • zapret не помогает: перебор 10 стратегий nfqws2 (multidisorder, multisplit, fake с md5/badsum/ts/autottl/seq, seqovl) дал тот же ~1 из 16, что и без обхода. googlevideo.com в egress-hosts.txt оставлен по просьбе владельца (не мешает: manifest/redirector открываются за 0,03 с).
  • egress не вариант: SSH-SOCKS до <IP_AI_EGRESS> тянет ~220 КБ/с (напрямую — 27 МБ/с).
  • aria2c не вариант: он сначала одним соединением узнаёт размер — те же минуты.
  • Решение — racefd.RaceFD: подменяет загрузчик yt-dlp для https-файлов с googlevideo известного размера (без прокси). Сразу пачкой открывает 12 соединений, бережёт их (keep-alive, переиспользуются между видео и звуком, недочитанный ответ не бросается), качает кусками по 1 МиБ; хвост «добивают» дублированием. Итог: 1080p + звук (105 МБ) за 6–9 с вместо минут простоя.
  • Режут по IP конкретного видеосервера, а не по SNI: к одному rr* новые соединения не проходят вовсе, к соседнему в тот же момент — все. Поэтому RaceFD, когда живых соединений меньше половины, в фоне заново разбирает ролик (_fresh_url) и добавляет «зеркало» — тот же формат и размер на другом сервере; новые соединения идут к серверу с лучшим счётом. Проверено также hostfakesplit:host=ozon.ru:tcp_ts=-1000:tcp_md5:repeats=4 — как без обхода. Выключатель: YTG_YT_PARALLEL=0.
  • Общий список заблокированных серверов (hostban.py, data/hostban.sqlite3): если к IP видеосервера подряд YTG_BAN_FAILS (8) соединений не прошло ни одно, IP заносится в список на YTG_BAN_TTL_SEC (15 мин). Все загрузки — любых роликов — такой IP обходят: сразу берут тот же формат с другого сервера (ссылки на все форматы кэшируются из каждого разбора), а если другого пока нет — ждут до 6 с, пока параллельный разбор (3 за раунд) его найдёт. Удачное соединение снимает бан. Список виден в perf_report.py.
  • Запасной путь через egress-пул: если все прямые серверы ролика в бане (YouTube выдаёт на ролик всего 2-3 кластера, и бывает, что заблокированы все) или 6 с нет ни байта, RaceFD разбирает ролик ЧЕРЕЗ egress (ссылка привязана к IP запросившего) и качает куски через пул туннелей, по туннелю на поток; прямые попытки идут параллельно. Провайдер режет каждое соединение до узла до ~300-400 КБ/с, поэтому туннелей 32: ytgrab-egress-pool.target → ytgrab-egress-pool@18110..18141.service (SOCKS на 127.0.0.1, ssh -D к <IP_AI_EGRESS>:10222, как основной туннель, от vpnbot не зависит). В юните сервиса YTG_EGRESS_POOL=socks5://127.0.0.1:18110-18141 — именно socks5:// (резолв у нас, IPv4): с socks5h:// узел ходит к Google по своему IPv6, а этот путь медленный (~0,2 МБ/с против 3-4 МБ/с по IPv4 на 12-16 потоках). Процесс загрузки вообще работает только по IPv4 (jobrunner._force_ipv4). Проверка: curl -x socks5h://127.0.0.1:18115 https://ipinfo.io/ip → <IP_AI_EGRESS>. Зависимость: PySocks (в requirements.txt). Туннели пула проверяют связь каждые 10 с (ServerAliveInterval=10, CountMax=3): подвисший туннель ssh закрывает примерно за 30 с, systemd поднимает заново. Поток загрузки после неудачи переходит на следующий туннель пула сам, не дожидаясь этого. Бывает «живой труп»: ssh держит связь (проверка проходит), а данные через туннель не идут — такой systemd не перезапустит. 13.10.2026 им был 18110, первый в пуле, а разбор ролика через egress брал именно первый (EGRESS_POOL[0]): загрузка висела на подготовке при 31 рабочем туннеле. Теперь туннель для разбора выбирает downloader.egress_pool_proxy — случайный, с проверкой, что соединение доходит до YouTube. Найти мёртвые: for p in $(seq 18110 18141); do curl -s -m 8 -x socks5://127.0.0.1:$p -o /dev/null -w "$p %{http_code}\n" https://www.youtube.com/generate_204 & done; wait — не 204 лечится sudo systemctl restart ytgrab-egress-pool@<порт> (по одному, не всем пулом — см. ниже про шторм).
  • Туннели не должны штормить. sshd на ai-egress (OpenSSH 10) держит PerSourcePenalties: соединения, не дошедшие до аутентификации, штрафуют НАШ IP, и на время штрафа узел отказывает всем SSH-соединениям с хоста (journalctl -u ssh на узле: «drop connection … penalty»). 27.09.2026 32 туннеля, перезапускавшиеся одновременно раз в 5 с, копили штраф сами: 296 перезапусков в час. Поэтому в юнитах: старт разнесён по экземплярам (ExecStartPre — 0–15 с), пауза между перезапусками растёт 5 с → 180 с (RestartSteps/RestartMaxDelaySec). Не проверять порт 10222 узла голым TCP-подключением (</dev/tcp/…, nc -z) — это тоже штраф. Юниты пула и туннеля — в deploy/ (источник), ставятся копированием в /etc/systemd/system/ + systemctl daemon-reload; tests/test_deploy_units.py держит эти свойства.
  • Антибот YouTube («Sign in to confirm you're not a bot») — YouTube может перестать отдавать ролики нашему IP целиком (было 23.09.2026 после ночи тестов с частыми повторными разборами). Распознаётся как бан (_BAN_SIGNATURES
    • «ролик без форматов» при ignore_no_formats_error): probe уходит через egress, а загрузка идёт racefd целиком через пул (_task_proxy → первый прокси пула, racefd в режиме egress_only). Чтобы не провоцировать антибот: не больше 3 раундов повторного разбора на файл, по 2 разбора за раунд. Проверка: yt-dlp --simulate <ролик> напрямую → «not a bot» = IP под антиботом.
  • Куски и соединения: 12 соединений кусками по 1 МиБ; большие файлы (от 64 МБ, 4K) — кусками по 4 МиБ, 16 соединений напрямую / 32 через egress-пул. YouTube ограничивает частоту запросов к одной ссылке и отвечает HTTP 401: 4K кусками 1 МиБ × 32 — 2 МБ/с и 1408 отказов из 1509, кусками 4 МиБ × 16 — ~100 МБ/с без отказов. На 401/429 поток ждёт 2 с. HTTP 403 — не DPI, а мёртвая ссылка: 6 подряд — ссылку бросаем (сервер в бан не идёт), новую берём разбором; все мёртвые и раунды кончились — отказ сразу, исполнитель разбирает ролик заново (раньше 5000 × 403 и 14 мин «обработки»). Виды ошибок соединений пишутся в журнал: journalctl -u ytdlp-gui | grep racefd.
  • «Авто» = лучшее из лучших (02.10.2026, downloader.YT_FORMAT_SORT, общий с jobrunner): разрешение → fps → SDR выше HDR → https → язык/quality → битрейт видео (vbr). Дефолт yt-dlp ставит первым AV1, а YouTube отдаёт AV1 самым урезанным (1080p 0,75 Мбит/с против 1,44 у H.264; 4K 10,5 против 16,2 у VP9). br/tbr/size в сортировку не ставить: у звука vbr нет, а с ними выбирался дубляж или DRC вместо оригинала. Видео отбирается по лимиту размера (size_filter: filesize/filesize_approx/tbr с запасом под звук) — «Авто» берёт лучшее, что влезает в MAX_FILESIZE_MB; к отрезку не применяется. Селекторы — bv*+ba (готовый файл со звуком тоже кандидат), «1080p» у вертикальных — по ширине. probe отдаёт picks — что сервер выберет на «Авто» и на каждую высоту; интерфейс показывает подпись и размер по ним. Качество между роликами не запоминается — каждый начинается с «Авто».
  • Плейлисты и каналы YouTube (list= без v=, /@канал, /playlist) — probe_list: только список роликов (extract_flat, до 200), ~0,4 с; интерфейс показывает список, клик открывает ролик. Раньше yt-dlp разбирал каждый ролик списка — минуты «Проверяем…» и ошибка «нет форматов». Плейлист архивом (playlist: true в /api/downloads): сервер сам заново получает список (от клиента — только ссылка), не больше YTG_PLAYLIST_DL_MAX (50) роликов, архив в пределах MAX_FILESIZE_MB. Исполнитель качает ролики по очереди с паузой 1-3 с (разборы подряд провоцируют антибот), недоступные пропускает (ignoreerrors), длиннее лимита — отсекает match_filter, и собирает ZIP «001 - Название.ext» (jobrunner.bundle_playlist).
  • Посты с несколькими видео (твит, карусель Instagram, пост boosty): форматы у записей, а не у поста. probe отдаёт multi_count, сервер (число — из своего разбора, dl.multi_count) качает их путём плейлиста: все записи, ZIP «001 - Название.ext». Раньше — «Файл не найден после скачивания». Смешанная карусель (фото + видео) — тоже пост, не галерея: ролики путём плейлиста, фото следом (POST_IMAGES_KEY), номера сквозные по карусели. Немые ролики (has_audio: false) — кнопка «Аудио» выключена.
  • Отрезок YouTube — «по запросу» (racefd.SectionPP / SectionSession): yt-dlp режет отрезок штатно (download_ranges, один ffmpeg с -ss/-t на каждый вход, -c copy — без перекодирования), а ffmpeg по индексу файла (sidx/Cues) читает только голову и сам отрезок. Напрямую к googlevideo ffmpeg не пускаем (нет нашего IP, обходов DPI, зеркал): постпроцессор before_dl подменяет ссылки дорожек на локальный HTTP-сервер (127.0.0.1, случайный порт и токен), а тот отдаёт байты, которые RaceFD в режиме «по запросу» качает в разреженный файл-подложку (в /tmp, удалён сразу после открытия — мусора не остаётся). Повторные разборы и egress в этом режиме — только если нужный кусок не идёт 2 с (простой ffmpeg — не повод дёргать YouTube). Прогресс — сколько отдано ffmpeg. Замер: 4 мин 1080p из ролика в 3,5 ГБ — 25 МБ за 9 с. Режим «только через egress» (антибот на нашем IP) тоже идёт по запросу через пул туннелей: 2 мин 1080p из 2,6 ГБ — ~10 с. Чтобы не тратить узкий egress: упреждение ограничено оценкой конца отрезка, а кусок дублируется, только если по нему молчат (1 с напрямую, 3 с через egress), а не просто медленно идёт. Если дорожки не для RaceFD (HLS), отрезок снимается: ролик целиком + jobrunner.trim_file, потолок YTG_CLIP_SOURCE_MAX_MB.
  • HLS-форматы (616 и т.п., «премиальные») качает штатный загрузчик без смены сервера, поэтому для YouTube format_sort предпочитает https при равном разрешении (jobrunner.YT_FORMAT_SORT).
  • Метрики скорости: каждая загрузка пишет строку в таблицу perf базы статистики (data/stats.sqlite3): домен, исход, тайминги (очередь, до первого байта, скачивание, обработка), скорость, сколько серверов понадобилось, доля непробившихся соединений. Без ссылок и IP, хранится 30 дней (YTG_PERF_RETENTION_DAYS). Сводка: cd /home/codex-pve/ytdlp-gui && .venv/bin/python perf_report.py 24 (число — за сколько часов).
  • Диагностика: for i in $(seq 10); do curl -s -o /dev/null -w "%{time_appconnect}\n" --max-time 5 https://rr5---sn-4g5lznle.googlevideo.com/; done — пустые/нули = соединение повисло.

«Для старого телевизора» (18.10.2026)

Галочка в настройках видео: tv: true в /api/downloads → флаг TV_KEY в extra → после загрузки исполнитель (jobrunner.Job.transcode_tv) перекодирует файл в MKV: H.264 High@4.0 (B-кадры), не выше 720p, AAC-LC 48 кГц стерео. Только одиночное видео (не аудио, плейлист, эфир, фото).

  • Почему так. Проверено на Philips 24PFL4228T (2013, USB, FAT32). Потоки с YouTube «как есть» не играли: ни исходное видео (Main, B-кадры), ни исходный звук AAC 44,1 кГц — играет только когда перекодированы ОБА потока. 720p High с B-кадрами и AAC 48 кГц — играет.
  • MKV, а не MP4. 70-минутный MP4 телевизор не открыл (индекс moov ~3 МБ), 10 и 25 минут играли; такой же 70-минутный MKV — играет. Не возвращать MP4 для длинных файлов, не резать на части.
  • Потолок качества 720p (экран 1366×768): «Авто» и 1080+ при галочке становятся 720p, меньшие высоты сохраняются. Битрейт — tv_video_kbps: не выше 4 Мбит/с и так, чтобы результат влез в MAX_FILESIZE_MB.
  • Нагрузка: перекодирование идёт в процессе исполнителя (под CPUWeight=20, Nice=10), время ограничено YTG_PROCESSING_TIMEOUT_SEC (час без событий; прогресс ffmpeg идёт событиями, так что сторож не срабатывает).

Движок yt-dlp: обязательные спутники

  • curl_cffi (в requirements.txt) — браузерный impersonate. Без него сайты за Cloudflare/анти-ботом (kick.com, pornhub) отдают 403/404. yt-dlp подхватывает автоматически.
  • deno (в /usr/local/bin, есть в дефолтном PATH systemd) — JS-runtime для nsig-челленджа YouTube. Без него в логах предупреждение «No supported JavaScript runtime», часть форматов пропадает, скорость рвётся. Ставится бинарём с github релизов; кэш пишет в $HOME/.cache/deno (HOME=/tmp, PrivateTmp — писать можно).
  • Диагностика «сайт не открывается»: openssl s_client -connect <ip>:443 -servername <host> → Terminated = SNI-DPI (добавить в egress-hosts.txt); 403/404 = нужен curl_cffi; 429/логин = ограничение сайта (напр. Instagram stories требуют авторизации — не чинится).

Ограничения

  • DRM не поддерживается (Widevine/PlayReady/FairPlay): Netflix, Spotify, Apple Music и пр. yt-dlp не расшифровывает — это не обходится настройкой.
  • Instagram stories/highlights требуют авторизации (429 без кук); DPI обойдён, но приватный контент без логина не берётся. Публичные посты и фото-галереи работают (режим kind=image качает картинки в ZIP).
  • TikTok блокирует серверные IP на уровне приложения («Your IP address is blocked») — это НЕ DPI, zapret не помогает. Решено запасным egress-прокси (см. ниже): probe при бане повторяет через него, TikTok работает.

Запасной egress-прокси для забаненного контента

Некоторые сайты (TikTok и пр.) банят серверный IP на уровне приложения. Механизм в коде: probe пробует напрямую и ТОЛЬКО при ошибке бана (_BAN_SIGNATURES: IP-блок/гео/403) повторяет через YTG_EGRESS_PROXY; если помогло, скачивание тоже идёт через него (флаг via_egress). Через прокси гонится не весь трафик, а только забаненное.

  • Прокси: SSH-туннель с динамическим SOCKS5 на 127.0.0.1:18099, выход с IP узла ai-egress <IP_AI_EGRESS> (чистый IP). Только петля — открытого прокси наружу нет.
  • Сервис туннеля: ytgrab-egress-tunnel.service (независим от vpnbot): ssh -p 10222 -i /root/.ssh/id_ed25519 -N -D 127.0.0.1:18099 root@<IP_AI_EGRESS>, Restart=always. Узел доступен по SSH на порту 10222 тем же root-ключом.
  • Подключение: в ytdlp-gui.service задано Environment=YTG_EGRESS_PROXY=socks5h://127.0.0.1:18099 + мягкая зависимость (Wants=ytgrab-egress-tunnel.service).
  • Проверка: curl -x socks5h://127.0.0.1:18099 https://ipinfo.io/ip → должен вернуть <IP_AI_EGRESS>.
  • Рестарт прода теряет незавершённые задачи (состояние в памяти одного процесса).

Оформление ответа

Правила оформления ответа общие для всех проектов: раздел «Оформление ответа» в /home/codex-pve/AGENTS.md. Прочитай его и соблюдай в каждом сеансе; менять правила только там.