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

408 lines
39 KiB
Markdown
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.

# 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`. Прочитай его и соблюдай в каждом сеансе; менять правила только там.