tg-mpv-bot: пульт от моего телевизора теперь — Telegram

Перестроил свой хак Telegram→mpv в самостоятельного open-source бота: библиотека на inline-кнопках, стриминг любой ссылки, файлы из Telegram до 2 ГБ на телевизор — плюс сетевые военные истории (мёртвый IPv6, CDN с привязкой к IP, MP4, заклинивший пайп), которые и сформировали архитектуру.

Терминал: tg-mpv-bot управляет воспроизведением mpv из Telegram

Мой домашний сервер стоит в гостиной, подключённый к телевизору по HDMI. Какое-то время назад я подружил mpv с Telegram через Hermes-агента — шелл-скрипт, немного YAML, quick-команды. Работало, и я был доволен. Железо за этим сетапом — тот самый старый ThinkPad из статьи про домашний сервер в гостиной.

Но хотелось большего: листать библиотеку кнопками, а не вспоминать названия; кинуть ссылку на YouTube — и чтобы просто заиграло; переслать видеофайл из чата прямо на телевизор. Ничего из этого не помещается в шелл-скрипт за LLM-гейтвеем. Так что я вынес всё в отдельного бота — и пошёл строить.

Результат — tg-mpv-bot: self-hosted Telegram-бот, превращающий любую Linux-машину с HDMI в медиацентр. Без облака, без AI, без проброса портов и приложений для смарт-ТВ: бот разговаривает с JSON IPC-сокетом mpv напрямую и устанавливает только исходящие соединения.

Стек
Python, aiogram, mpv IPC, yt-dlp, uv
Сценарий
Управление просмотром на ТВ из Telegram
Установка
AUR · ghcr.io/antlis/tg-mpv-bot
Лицензия
MIT

Этот пост — о том, что получилось, и (что интереснее) о том, что ломалось по дороге. Одна только сетевая часть стоила мне ночи жизни.

Что он умеет

Схема tg-mpv-bot: Telegram отправляет команды боту на домашнем Linux-сервере, бот управляет mpv через IPC, использует yt-dlp и прокси для внешних медиа и выводит картинку на телевизор по HDMI.
Telegram — только пульт. Воспроизведение остаётся локальным: tg-mpv-bot управляет mpv на машине, уже подключённой к телевизору.

Команд около сорока, но ежедневный цикл маленький:

  • /mpv_list — библиотека на inline-клавиатурах (категория → плейлист), и сверху строка «▶ Continue: последнее просмотренное». Выбор серии, выбор главы, полнотекстовый поиск, «включи что-нибудь случайное».
  • Отправить ссылку — YouTube, SoundCloud, Instagram, всё, что переварит yt-dlp — и оно играет на телевизоре. Субтитры подтягиваются автоматически; /mpv_yt <запрос> ищет по YouTube прямо из чата, результаты с обложками.
  • Переслать видеофайл — до 2 ГБ с локальным Bot API сервером — и он играет.
  • Панель «сейчас играет» со всем транспортом на кнопках: пауза, перемотка к проценту, громкость, скорость, аудио- и субтитровые дорожки.
  • Бытовые мелочи: история просмотра с повтором в один тап, позиции стримов, переживающие рестарты, уведомления «⏭ Now playing: 6/12», когда серия закончилась сама, таймер сна, нормализация громкости для ночных просмотров и экран /mpv_health для неизбежного «а почему не работает».

Под капотом — Python + aiogram, упаковано через uv, в CI тесты гоняются против настоящего mpv --idle, релизы автоматически публикуются на GitHub и в ghcr по пушу тега.

Архитектура в один абзац

Всё рутинное — пауза, перемотка, громкость, переключение дорожек, прыжки по главам — это JSON-команда, записанная напрямую в Unix-сокет mpv (--input-ipc-server). Процессы бот порождает только для запуска воспроизведения, и этот путь обёрнут pre/post-play-хуками (PRE_PLAY_HOOK="i3-msg workspace 10" — клей для оконного менеджера живёт в вашем конфиге, а не в коде бота). Постоянный слушатель на том же сокете следит за потоком событий mpv — отсюда уведомления и чекпоинты позиции. Всё.

Военные истории

1. i3-сокет, который врал

Старая версия хардкодила I3SOCK=/run/user/1000/i3/ipc-socket.2012. Найдите баг: 2012 — это PID процесса i3, вшитый в путь. Каждая перезагрузка — новый PID, мёртвый сокет, и переключение воркспейса молча переставало работать. Лечение — удалить фичу: у бота теперь универсальные хуки запуска, а i3-msg сам находит свой сокет через X11. Если в конфиге лежит значение с PID внутри — это не конфиг, это бомба замедленного действия.

2. Сетевая сага yt-dlp

Стриминг по ссылке выглядел фичей на один вечер: у mpv есть встроенная интеграция с yt-dlp, просто передай URL. Реальность возражала всю ночь, слоями:

Слой 1 — куки. Я включил cookies-from-browser=firefox глобально, чтобы работали сайты с логином. Оказалось, куки залогиненного YouTube-аккаунта заставляют экстракцию yt-dlp подвисать на ~45 секунд на каждое видео. Теперь куки применяются только к хостам, которым они нужны, плюс одноразовая эскалация, когда YouTube показывает свою страницу «подтвердите, что вы не бот».

Слой 2 — протухшие экстракторы. YouTube ломает yt-dlp раз в несколько месяцев, а дистрибутивные пакеты отстают. Бот теперь держит nightly-сборку в собственном venv, предпочитает её системной, даёт команду /mpv_update_ytdlp (один тап с дивана, когда YouTube что-то поменял) и умеет автообновляться раз в неделю.

Слой 3 — раздвоение личности. Мой прокси-TUN перехватывает только IPv4: v4-трафик уходит через туннель, v6 — по голой линии провайдера. Почему это важно? Потому что URL-ы googlevideo привязаны к IP-адресу, с которого их выписали. yt-dlp резолвит ссылку по одному пути, mpv скачивает по другому — и CDN душит несовпадение. «Фикс» в виде принудительного IPv6 наглухо сломал SoundCloud (у их CDN нет AAAA-записей). Каждый слой этой луковицы прятал следующий.

Слой 4 — настоящая причина. Намучившись, я сделал замер, с которого стоило начинать:

curl -4 https://<media-cdn> → 200 за 0.5 с (через туннель)
curl -6 https://<media-cdn> → таймаут TLS-хендшейка (линия провайдера)

IPv6 моего провайдера просто не дотягивается до половины медийных CDN интернета. Все «загадочные» сбои — таймауты проб, зависания mpv, то, что я принял за рейт-лимит, — были трафиком, случайно ушедшим в v6.

Архитектура, которая наконец держится:

  • YouTubeyt-dlp -o - | mpv -. yt-dlp качает всё сам (та же форма, что у обычной загрузки, которая доказуемо работает), и IP-привязанные URL не покидают процесс, который их выписал. Раздельные видео- и аудиопотоки не могут жить в одном пайпе — перемешанные байты это мусор — поэтому каждый поток получает свой fd, а муксит их mpv.
  • Всё остальное → mpv получает разрезолвленный URL напрямую, и оба — проба yt-dlp и сам mpv — ходят через один явный прокси (MEDIA_PROXY: yt-dlp --proxy + mpv --http-proxy). Один egress от начала до конца; IPv6 не участвует вовсе.

3. MP4, который съел 600 МБ и не показал ничего

Почему тогда не гонять всё через пайп? Из-за одного видео с обычного видеохостинга, которое «прекрасно играет через mpv <url> на ноутбуке», а через бота показывало чёрное ничто. Логи рассказали красивую историю: yt-dlp протолкнул через пайп сотни мегабайт, а mpv всё ещё сидел на Reading from stdin....

Файл оказался прогрессивным MP4 с moov-атомом — индексом, без которого плеер не может начать, — в самом конце 858-мегабайтного файла. При прямом воспроизведении плеер range-запросом достаёт хвост и стартует мгновенно. Пайп перематывать нельзя — и mpv ждал индекс, который придёт последним, с буфером демуксера меньше самого файла. Дедлок с прогресс-баром. В тот день и родилось разделение на пайп/директ.

4. Файлы из Telegram на 2 ГБ и три системы прав

Пересылка видеофайлов на телевизор звучала тривиально — у aiogram есть bot.download(). Три грабли спустя:

  • Стандартный Bot API ограничивает скачивание 20 МБ. Нужен локальный Bot API сервер, который в режиме TELEGRAM_LOCAL вообще не отдаёт байты — он возвращает пути в файловой системе внутри своего контейнера.
  • Ладно, монтируешь его каталог на хост… и выясняешь, что демон работает под uid 101 и создаёт каталоги с правами 750, которые твой бот прочитать не может. Entrypoint контейнера обязан стартовать под root (он делает chown и сбрасывает привилегии), так что user: в compose просто роняет его в crashloop. Ответ — файловые ACL с наследованием: они переживают чужие chown-ы.
  • А сыгранный файл лежит на диске вечно, пока его кто-нибудь не удалит, — так что бот теперь прибирает старые файлы после каждого нового (текущий оставляя — чтобы работало «продолжить просмотр»).

И да: не копируйте файл на 2 ГБ в /tmp на Arch. /tmp — это оперативка.

5. Отладка вслепую

Самый глупый час той ночи: ранние версии запускали mpv со stderr=DEVNULL. Любой сбой выглядел одинаково — «бот говорит, что играет, на телевизоре ничего». Стоило направить вывод mpv в лог-файл — и каждый оставшийся баг стал занимать минуты вместо часов, потому что ошибка просто лежала там, написанная обычным текстом. Отвязанные (detached) процессы обязаны куда-то логировать. Не обсуждается.

Из хака — в продукт

Самое приятное в выделении в отдельный проект — длинный хвост полировки, до которого у личного хака руки не доходят никогда: лендинг, pyproject.toml + uv.lock, CI, гоняющий IPC-клиент против настоящего mpv на трёх версиях Python, пакет в AUR, Docker-образы на ghcr, Dependabot с автомёржем зелёных патчей, защита ветки, автоматизация релизов. Репозиторий теперь по большей части обслуживает себя сам; я в основном нажимаю /mpv_update_ytdlp, когда YouTube чихает.

Старый Hermes-сетап остался для запросов на естественном языке («включи что-нибудь нуарное») — но повседневный пульт теперь — бот, который не тратит ни токена, играет всё, до чего дотянется yt-dlp, и помещается в один systemd user unit.

Попробовать

Terminal window
git clone https://github.com/antlis/tg-mpv-bot && cd tg-mpv-bot
uv sync
cp .env.example .env # BOT_TOKEN от @BotFather, свой ID в ALLOWED_USERS
set -a; source .env; set +a
uv run bot.py

Или yay -S tg-mpv-bot-git на Arch. В README — раскладка медиабиблиотеки, все переменные окружения и варианты деплоя. Issues и PR приветствуются — особенно если у вашего провайдера работает IPv6 и вы найдёте совершенно другой класс багов.