tg-media-bot: кидаешь ссылку — получаешь файл

Self-hosted Telegram-бот, который качает медиа с 1000+ сайтов через yt-dlp и присылает файл обратно — MP3 с тегами, видео для inline-просмотра, загрузки до 2 ГБ, живой прогресс-бар. Плюс военные истории: три бага в коде, который выглядел готовым, загрузка, которую нельзя измерить, и cookies.txt, ломающий тот самый сайт, которому должен был помогать.

Терминал: tg-media-bot скачивает ссылку через yt-dlp и отправляет файл

tg-mpv-bot играет ссылки на моём телевизоре. Но не реже мне нужно не посмотреть что-то на большом экране, а получить сам файл: трек на телефон, клип в офлайн, сет с SoundCloud как MP3 с тегами. Так что у него появился брат. Он крутится на том же старом ThinkPad из статьи про домашний сервер в гостиной.

tg-media-bot — небольшой self-hosted Telegram-бот: кидаешь ему ссылку (с любого из 1000+ сайтов, которые поддерживает yt-dlp) — и он скачивает медиа и присылает файл обратно в чат. Чистая утилита. Без AI, без аккаунтов, без трекинга; крутится на твоей машине и устанавливает только исходящие соединения.

Стек
Python, aiogram, yt-dlp, local Bot API, pytest
Сценарий
Превращать медиа-ссылки в файлы внутри Telegram
Установка
AUR · ghcr.io/antlis/tg-media-bot
Лицензия
MIT

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

Что он умеет

Схема tg-media-bot: Telegram отправляет ссылку боту на домашнем Linux-сервере, бот ставит задачу в очередь, скачивает и обрабатывает файл через yt-dlp, загружает его через локальный Telegram Bot API server и отправляет файл обратно в чат.
Telegram здесь — вход и доставка. Работа остаётся на домашнем сервере: поставить ссылку в очередь, скачать через yt-dlp, обработать файл и отправить его обратно через локальный Bot API server.
  • Кидаешь ссылку (до трёх за сообщение) — и он качает в текущем режиме и присылает файл. YouTube, SoundCloud, TikTok, Instagram, Reddit, Twitch, Vimeo, X — всё, до чего дотягивается yt-dlp.
  • /audio отдаёт нормальный MP3 с тегами — вшитая обложка, превью в плеере, название/исполнитель/длительность. SoundCloud всегда аудио, без команды. /video — режим по умолчанию; не-MP4 источники перекодируются в стримящийся H.264/AAC, чтобы Telegram играл их inline.
  • /formats <url> показывает кнопки выбора качества — Best / 1080p / 720p / 480p / Audio.
  • Загрузки до 2 ГБ через встроенный локальный Telegram Bot API server (вместо стандартных 50 МБ), с живым прогресс-баром во время скачивания.
  • Мгновенные повторы: просишь ссылку, которую уже кто-то качал, — и она прилетает за секунду, прямо из кэша Telegram.
  • Доступ по allowlist (работает и в личке, и в группах, которые активировал разрешённый пользователь), пер-юзер рейт-лимит, реально работающий глобальный лимит параллельных загрузок, настоящий /cancel и автоочистка временных файлов.

Внутри — Python + aiogram поверх yt-dlp, юнит-тесты на pytest в CI, мультиарх Docker-образ и пакет в AUR.

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

1. Три бага в коде, который выглядел готовым

Скелет существовал ещё до того, как я взялся всерьёз, и выглядел законченным — типизированный, задокументированный, с тестами. Три вещи тихо врали:

  • Глобальный лимит параллельности не делал ничего. Был семафор под MAX_PARALLEL_DOWNLOADS… который никто никогда не захватывал. Реальный путь запускал каждую загрузку сразу. Лимит был украшением.
  • /cancel ничего не отменял. Он переставлял статус задачи в cancelled — а процесс yt-dlp как ни в чём не бывало продолжал качать и всё равно загружал результат. Отмена была ярлыком, а не действием.
  • DOWNLOAD_TIMEOUT нигде не применялся. Считывался из env, задокументирован в README, не подключён ни к чему. Зависшая загрузка висела вечно.

Ни один из них не падает с ошибкой. Ни один не валит тест, который не написан. Это самый опасный тип багов: код, который читается как правильный. Фиксы — реально захватывать слот в раннере, держать ссылку на asyncio-задачу, чтобы отмена могла .cancel() её и убить процесс, обернуть подпроцесс в wait_for — оказались крошечными. Чтобы их найти, надо было не верить, что фича с ручкой в конфиге и докстрингом действительно существует.

2. Загрузка, которую нельзя измерить

Прогресс-бар просили, и со скачиванием всё просто: запускаешь yt-dlp с --newline --progress-template, читаешь stdout построчно, троттлишь правки, чтобы Telegram не зарейтлимитил, рисуешь █░-бар. Готово.

А вот загрузка — стена. У Bot API нет колбэка прогресса загрузки — а с локальным Bot API server ещё хуже: бот отдаёт файл серверу на той же машине (быстро), а тот уже грузит в дата-центры Telegram полностью вне зоны видимости. Считать байты неоткуда. (Мой tg-mpv-bot умеет показывать % загрузки только потому, что говорит на сыром MTProto через другую библиотеку; Bot API этого просто не даёт.)

Так что я перестал притворяться. Статус теперь идёт честными фазами: 📥 Скачивание (настоящий бар) → 🔄 Обработка (этап склейки/перекодировки, который иначе залипает на запутывающих 100%) → 📤 Загрузка с хартбитом — прошедшее время + размер. Не фальшивый процент, а сигнал «живой, работаю», что как раз и нужно знать пользователю.

3. cookies.txt, ломающий сайт, которому должен был помогать

Чтобы качать закрытый контент (Instagram, видео с возрастным ограничением), бот умеет использовать cookies.txt. Я положил его — и YouTube тут же начал падать:

ERROR: [youtube] …: Requested format is not available

Тот же ролик прекрасно качался без куки. Воспроизведение всё прояснило: с куками yt-dlp аутентифицируется как залогиненная сессия, и YouTube отдаёт ей ответ «tv downgraded» player с набором форматов, который мой селектор не мог удовлетворить, — иногда вообще без пригодных форматов. Куки, предназначенные для Instagram, травили YouTube.

Выборочно подсунуть куки одному хосту из одного файла нельзя, поэтому фикс — это фолбэк по образцу гео-ретрая: если загрузка падает с ошибкой формата/экстракции и куки были задействованы — повторить один раз без них, сохранив исходную ошибку, если и ретрай не удался. Куки по-прежнему помогают сайтам, которым они нужны, и больше не топят те, которым вредят. (Та же семья уроков, что и в tg-mpv-bot, где залогиненные куки YouTube добавляли 45 секунд залипания. Куки — это заряженное ружьё, направленное на YouTube.)

4. file_id — повторная загрузка, которая достаётся бесплатно

Telegram выдаёт каждому загруженному файлу ID, и переотправка по file_id мгновенна и бесплатна — без перекачивания и перезагрузки. Поэтому бот кэширует (url, format) → file_id. Просишь ссылку, которую уже кто-то качал, — он вообще пропускает очередь и присылает файл за секунду. На редкий протухший ID он тихо выкидывает запись и качает заново. Это самое большое ускорение в повседневной жизни — и почти без кода: просто берёшь то, что платформа уже сама даёт.

5. 64 байта callback_data

Выбор качества — единственный кусок настоящего UI в боте: жмёшь кнопку — получаешь это разрешение. Inline-кнопки Telegram несут полезную нагрузку callback_data, ограниченную 64 байтами. URL туда не влезает. Поэтому ссылка складывается на сервере под коротким токеном, а кнопка несёт только q:<token>:<choice>. Мелкое ограничение — но именно оно определяет весь дизайн взаимодействия.

От скрипта к пакету

То, что делает это проектом, а не сниппетом: реально работающая очередь, настоящий /cancel, понятные сообщения об ошибках, переводящие сырые сбои yt-dlp в действие («возрастное ограничение → задай COOKIES_FILE»), лендинг, CI с pytest на двух версиях Python, мультиарх Docker-образ на ghcr и пакет в AUR. В Docker он ещё и обновляет yt-dlp при каждом старте — так что когда YouTube что-то меняет, весь фикс — это рестарт.

Попробовать

Terminal window
git clone https://github.com/antlis/tg-media-bot && cd tg-media-bot
cp .env.example .env # BOT_TOKEN от @BotFather, твой ID в ALLOWED_USERS
docker compose up -d # тянет готовый образ + локальный Bot API server

Или paru -S tg-media-bot на Arch. README описывает каждую переменную окружения и путь через голый Python. Issues и PR приветствуются.