tg-streaming-bot: киновечера в голосовом чате Telegram

Draft

Собственный Telegram-бот, который стримит фильмы, музыку, интернет-радио и 50 000+ каналов живого ТВ прямо в групповой голосовой чат — чтобы друзья из разных городов смотрели одно и то же, синхронно, хоть с телефона. Плюс боевые истории: почему бот в принципе не может сделать это в одиночку, стриминг YouTube по принципу «скачай или ничего» и пауза, которая сама себя снимала.

Терминальная карточка: tg-streaming-bot играет фильм в голосовом чате Telegram, друзья смотрят вместе

Часть моих друзей теперь живёт в других городах. Старый ритуал — собраться на одном диване, включить фильм, комментировать его — не переживает расстояния. Инструменты демонстрации экрана хотят, чтобы все были в одном приложении с хорошей скоростью отдачи; они захлёбываются на 1080p и умирают, как только кто-то сидит с мобильного интернета. А что у всех, кого я знаю, и так открыто весь день на каждом устройстве — это Telegram.

Групповые звонки в Telegram умеют передавать видео. Не только веб-камеру, а настоящую трансляцию в звонок, которую все участники видят одновременно. Так что идея была маленькой и упрямой: направить бота в групповой голосовой чат, сказать ему включить фильм — и пусть четыре человека из трёх городов смотрят одни и те же кадры в один и тот же момент с того устройства, что под рукой.

Результат — tg-streaming-bot, self-hosted бот, который стримит музыку и видео в групповые голосовые чаты Telegram. Фильмы, YouTube, файл, который кто-то кинул в чат, интернет-радио или живое ТВ из каталога на 50 000 каналов — всё это попадает в звонок, а полное управление (пауза / перемотка / громкость / пропуск) висит inline-кнопками, общими для всех в комнате.

Если вы видели мой более ранний tg-mpv-bot — это та же идея, направленная на другой экран. tg-mpv-bot — это Telegram-пульт для телевизора в моей гостиной: он управляет mpv, подключённым по HDMI, так что воспроизведение попадает на один физический экран в одной комнате. tg-streaming-bot сохраняет ощущение «Telegram как пульт», но меняет вывод: вместо стриминга через mpv на мой телевизор он стримит в групповой видеочат Telegram — так что «экраном» становится телефон каждого сразу, где бы человек ни был. Та же идея — управлять медиа из чата, — только ради просмотра вместе, а не дома.

Стек
Python, Pyrogram, py-tgcalls, yt-dlp, ffmpeg (VAAPI)
Сценарий
Смотреть/слушать вместе в групповом звонке Telegram
Развёртывание
docker compose up -d
Лицензия
GPL-3.0
Поставь трек в очередь — его слышат все в группе: управление воспроизведением, радио, живое ТВ, всё из чата.

Что он умеет

Схема tg-streaming-bot: группа в Telegram отправляет боту команды; бот скачивает медиа через yt-dlp, перекодирует через ffmpeg, а ассистент-аккаунт под управлением py-tgcalls передаёт поток в групповой голосовой чат, где все друзья смотрят вместе.
Telegram — это комната. Бот принимает команды; а в звонок заходит и стримит отдельный аккаунт-ассистент; все в группе видят одни и те же кадры.

Повседневных действий немного, а вот список источников — нет:

  • /vplay <запрос | URL> — стрим фильма или клипа в звонок. YouTube (поиск или ссылка), видеофайл из чата, Rutube / Vimeo / любой сайт, который знает yt-dlp, или живой .m3u8. Добавьте 720/480/360, чтобы ограничить качество, mute — чтобы начать без звука.
  • /play, /search — то же для музыки: включить первый результат или выбрать из списка выдачи YouTube (🎵 аудио / 🎬 видео).
  • /radio — десятки станций интернет-радио, с текущим треком на карточке.
  • /iptv <название> — живое ТВ. Ищет по каталогу iptv-org (50 000+ каналов) по названию и стримит, вместе с логотипом канала. Удобно поставить всем футбольный матч.
  • /library, /lplay — просмотр и воспроизведение собственных папок с медиа, с выбором аудиодорожки и субтитров.
  • Общее управление — одна панель «сейчас играет» с паузой, продолжением, пропуском, перемоткой, перемоткой по %, общей громкостью и выключением звука. Громкость именно общая: она меняет сам поток, так что изменение слышат все в звонке, а не только тот, кто нажал.
  • Дополнительно — запись стрима в файл, скриншот текущего кадра, /autoplay (авто-диджей похожих треков, когда очередь пустеет), SponsorBlock для вырезки спонсорских вставок YouTube, loop / shuffle / clear и /topic lock, чтобы привязать бота к одной теме форума.

Под капотом — Python + Pyrogram, стриминг через py-tgcalls, yt-dlp для загрузки и ffmpeg (опционально с VAAPI-ускорением) для перекодирования в то, что примут звонки Telegram.

Главное, что определяет всё: бот не может зайти в звонок

Это факт, вокруг которого построен весь проект, и его стоит сказать прямо, потому что меня он удивил: бот-аккаунт Telegram не может зайти в групповой голосовой чат. В bot API такого понятия просто нет. Воспроизведение в звонок доступно только пользовательскому аккаунту.

Так что tg-streaming-bot — это на самом деле две личности Telegram, работающие вместе:

  • Бот — командный интерфейс. Живёт в группе админом (ему нужно право Управление видеочатами, чтобы начать звонок, и Удаление сообщений, чтобы держать панель в порядке). С ним вы и общаетесь.
  • Аккаунт-ассистент — настоящий пользовательский аккаунт, вошедший через строку сессии Pyrogram и управляемый py-tgcalls. Именно он заходит в голосовой чат и передаёт настоящие байты аудио и видео.

Бот командует, ассистент стримит. Из этого вытекает пара следствий, о которых узнаёшь, только наступив на них: ассистент должен быть участником каждой группы, где играет (команды воспроизведения приглашают его автоматически), и Telegram разрешает ровно одно голосовое подключение на аккаунт — так что ассистент обязан быть отдельным аккаунтом, а не тем, с которого слушает живой человек. Как только это уложилось в голове, всё остальное свелось к тому, чтобы подключать источники к потоку ассистента.

Боевые истории

1. YouTube: сначала скачай — или ничего

Очевидное решение — отдать стримеру ссылку, как mpv, и пусть тянет поток сам. YouTube это убил: прямые ссылки на потоки заблокированы, а путь «просто дай URL» возвращает 403, неотличимые от любых других ошибок. Работает неказистый вариант — сначала скачать файл (yt-dlp с мобильным клиентом плеера, H.264 + AAC в mp4), а потом стримить в звонок локальный файл. Даже клиент приходится менять: android_vr работал, пока тоже не начал получать 403, теперь это android. Перед стартом приходится подождать несколько секунд «Searching…», но это разница между надёжным киновечером и бесконечным спиннером. Ссылки не с YouTube (Rutube, Vimeo, IPTV) разбираются и стримятся сразу, без загрузки, но их HLS-токены истекают примерно через 30 минут, так что трёхчасовой фильм оттуда иногда приходится подтолкнуть.

2. Радио, которое работало везде, кроме контейнера

Интернет-радио казалось тривиальным — отдай ffmpeg ссылку на поток. На хосте всё работало, а в контейнере для нескольких станций ffmpeg падал с «Failed to resolve hostname». Сами DNS-ответы были в порядке. Встроенный DNS-резолвер Docker (127.0.0.11) неправильно обрабатывает ответы с цепочками CNAME для некоторых хостов, и getaddrinfo() из glibc возвращал EAI_NODATA для имён, которые снаружи прекрасно резолвились. Исправление — две строчки dns: в docker-compose.yml: контейнер ходит прямо в 1.1.1.1 / 8.8.8.8, минуя этот слой. Вдобавок /radio теперь один раз повторяет play(), потому что вход в звонок иногда сам по себе падает с временной ошибкой Telegram INTERDC_X_CALL_ERROR. «У меня работает» подозрительно часто оказывается историей про DNS.

3. Мастер-плейлисты IPTV и придирчивые CDN

Живое ТВ оказалось двумя багами в одном плаще. Во-первых, записи iptv-org часто оказываются мастер-плейлистами .m3u8 — меню вариантов по битрейту, а не потоком. Если отдать плееру такой плейлист напрямую, он может не выбрать ничего. Теперь бот перед стримом сам выбирает из мастер-плейлиста один конкретный вариант. Во-вторых, многие из этих CDN отклоняют запросы, не похожие на браузер, так что запрос должен нести настоящие User-Agent и Referer, иначе получишь вежливый 403. По отдельности несложно; и то и другое невидимо, пока не смотришь на чёрный прямоугольник, гадая, почему канал, работающий в VLC, не играет.

4. Пауза, которая снимала сама себя

Самый противный баг был не падением: /pause снимал паузу сам примерно через пятнадцать секунд, да ещё и отматывал немного назад. У бота есть сторож, который следит за зависаниями: если позиция воспроизведения не меняется 15 секунд, он считает, что поток умер, и перезапускает его с последней сохранённой позиции. Поставленные на паузу потоки он должен был пропускать, проверяя call.status == PAUSED, — но у объекта звонка в py-tgcalls нет .status (есть .playback), так что проверка молча никогда не срабатывала, и любая пауза выглядела как зависание.

Исправить имя атрибута оказалось мало. py-tgcalls сообщает о потоке на паузе как об IDLE, а не PAUSED — тот же статус, что и у оборвавшегося, — так что никакая проверка атрибутов их не различит. Настоящее исправление — перестать спрашивать драйвер и хранить состояние паузы явно: /pause и /resume ставят флаг, и сторож такие потоки не трогает. Два релиза, чтобы загнать его в угол, — из тех багов, где каждое исправление вскрывает следующий слой.

5. Хуже всего — обработчик, который падает молча

Поначалу команда, упавшая посреди стрима, просто… останавливалась, оставляя замороженную карточку «Searching…» на превью и ни намёка почему. Поэтому теперь каждый обработчик команд и кнопок обёрнут в общий перехватчик ошибок: сбой сам сообщает о себе обратно в чат (или всплывающим уведомлением — для кнопок), а не растворяется в docker logs. Отлаживать общий киновечер по SSH, пока четыре человека ждут, — сомнительное удовольствие; ошибка должна быть просто в чате.

Как я им пользуюсь на самом деле

В основном по пятницам. Кто-то начинает видеочат в группе, я запускаю фильм через /vplay — и мы смотрим вместе, ставим на паузу на перекусы (и это ставит паузу всем), спорим о дырах в сюжете в том же чате. В будни чаще /radio фоном, пока люди приходят и уходят, или /iptv, чтобы включить матч, когда идёт трансляция. Общая громкость оказалась важнее, чем я ожидал: возможность убавить фильм для всех, когда кто-то заговорил, с телефона — та мелочь, из-за которой это ощущается как одна комната, а не четыре.

Попробовать

Terminal window
git clone https://github.com/antlis/tg-streaming-bot && cd tg-streaming-bot
cp example.env .env # BOT_TOKEN от @BotFather, API_ID/HASH с my.telegram.org
# сгенерировать строку сессии ассистента (войдя под отдельным аккаунтом):
docker run -it --rm --env-file .env \
-v "$(pwd)/gen_session.py:/gen.py" python:3.11-slim \
sh -c "pip install -q kurigram tgcrypto && python3 /gen.py"
docker compose up -d --build

Дальше добавьте бота в группу админом, начните голосовой чат и запустите что-нибудь через /vplay. README описывает все переменные окружения, структуру локальной медиатеки, и VAAPI-перекодирование. Issue и PR приветствуются — особенно если найдёте источник, который yt-dlp умеет, а удобной команды для него у меня ещё нет.