A search reply carried ten markdown links, and Stoat expands every link into a full-size player — the result was a wall of embeds burying the list. Stoat's embed generator skips links inside code spans or angle brackets, so links now go out quietly, and list lines carry no links at all: title, artist, length, source. Only `!nowplaying` keeps a preview, where it was asked for. Choosing a track no longer needs a second command: the results message gets 1️⃣–5️⃣ reactions and picking one queues the track, with `!pick` still there when reactions fail or five results are not enough. Only the person who searched can pick, so a list cannot be hijacked. Also `!playvideo` to turn video on and queue a track in one go, and setVideo no longer requires a running player — the switch is a setting, and demanding a player made it fail on a server nothing had played on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stoat-mbot
Музыкальный бот для self-hosted Stoat с веб-панелью: поиск и воспроизведение в голосовых каналах, очередь, перемотка, громкость — из чата и из браузера, состояние синхронизировано в обе стороны через WebSocket.
Источники: YouTube, SoundCloud, прямые ссылки и интернет-радио, локальная медиатека (опционально).
Голос: LiveKit — тот же, что и в вашем инстансе (revoice.js + @livekit/rtc-node), звук готовит ffmpeg.
Вход в панель: учётными записями вашего же Stoat (пароль уходит прямо в /auth/session/login
вашего инстанса, бот его не хранит) либо одноразовой ссылкой по команде !panel.
Как это устроено
Stoat (чат) ──messageCreate──► bot/commands ─┐
├──► MusicManager ──► GuildPlayer ──► LiveKit
Браузер ──REST + WebSocket──► api/server ───┘ (очередь, (ffmpeg,
громкость, yt-dlp)
повтор)
Чат-команды и панель дергают один и тот же MusicManager, поэтому «нажал в браузере — увидел в чате»
работает без рассинхрона. Каждое действие панели повторно проверяет членство и права в Stoat,
так что доступ живёт в ролях Stoat, а не в отдельной базе бота.
| Слой | Файлы |
|---|---|
| Источники и yt-dlp | src/sources |
| Плеер и очередь | src/core/player.ts, src/core/manager.ts |
| Чат-бот | src/bot |
| REST + WebSocket | src/api/server.ts |
| Веб-панель (React) | web/src |
Установка рядом со Stoat
Предполагается раскладка /opt/stoat (инстанс) и /opt/stoat-mbot (этот репозиторий).
1. Клонировать репозиторий
/opt принадлежит root, поэтому клонируем под sudo и сразу возвращаем владение себе:
sudo git clone https://gitea.hsrv.site/mrleo1nid/stoat-mbot.git /opt/stoat-mbot
sudo chown -R $USER:$USER /opt/stoat-mbot
chown здесь не косметика: дальше вы правите .env и compose.yml, а bind-mount ./data
иначе создастся от root. Контейнер работает под пользователем node (uid 1000) — если ваш
пользователь тоже uid 1000 (id -u), права на data/ совпадут и yt-dlp сможет туда писать.
2. Создать бота в Stoat
Settings → My Bots → создать бота → скопировать токен → пригласить бота на сервер. Боту нужны права: читать сообщения, писать сообщения и подключаться к голосовым каналам.
3. Заполнить .env
cd /opt/stoat-mbot
cp .env.example .env
openssl rand -hex 32 # → JWT_SECRET
Минимум, что нужно указать:
STOAT_API_URL=https://chat.example.com/api
STOAT_DOMAIN=chat.example.com
STOAT_BOT_TOKEN=<токен бота>
PUBLIC_URL=https://music.example.com
JWT_SECRET=<случайные 32 байта>
STOAT_API_URL указывается публичный (https://домен/api): бот ведёт себя как обычный клиент —
ходит в API, gateway и LiveKit через тот же внешний прокси с валидным TLS, что и браузеры.
4. Про STOAT_DOMAIN
Бот запускается отдельным compose-проектом и с внутренним Caddy инстанса никак не связан.
STOAT_DOMAIN из .env подставляется в extra_hosts, и контейнер резолвит домен инстанса
в сам хост, где внешний Caddy держит 443 с валидным сертификатом — так бот не зависит от
NAT loopback на роутере. Сам compose.yml править не нужно, иначе git pull
будет конфликтовать с обновлениями.
5. Повесить панель на внешний Caddy
Порт панели публикуется только на локальный интерфейс (127.0.0.1:3005), домен выдаёт внешний
Caddy хоста — в /etc/caddy/Caddyfile (deploy/Caddyfile.snippet):
music.example.com {
reverse_proxy 127.0.0.1:3005
}
Затем sudo systemctl reload caddy. WebSocket проксируется автоматически.
6. Запустить
cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
В логах должно появиться yt-dlp detected, bot is ready и panel is listening.
Использование
В чате (префикс по умолчанию !):
| Команда | Что делает |
|---|---|
!play <ссылка или название> |
добавить трек/плейлист в очередь |
!playnext, !playnow |
следующим / немедленно |
!search <запрос> |
поиск: выбор реакцией 1️⃣–5️⃣ (или !pick <n>) |
!skip [n], !stop, !pause, !resume |
управление воспроизведением |
!queue [страница], !nowplaying |
очередь и текущий трек |
!volume [0-200], !loop [off|track|queue], !shuffle |
звук и порядок |
!remove <n>, !clear, !seek 1:23 |
правка очереди и перемотка |
!video [on|off] |
показывать клип вместе со звуком |
!playvideo <ссылка или название> |
включить видео и добавить трек |
!join, !leave |
зайти в ваш голосовой канал / выйти |
!panel |
личная ссылка на веб-панель (действует 10 минут) |
!help |
список команд |
Префиксы поиска: sc: — SoundCloud, yt: — YouTube, local: — локальная медиатека.
В панели то же самое выбирается списком слева от строки поиска; по умолчанию ищет везде сразу,
а префикс в запросе перебивает выбор в списке.
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками, клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
Ссылки в ответах бота намеренно оформлены так, чтобы Stoat не разворачивал их в превью: список из
пяти результатов иначе превращается в стену видеоплееров. Единственное исключение — !nowplaying,
где превью запрошено явно.
Если бот не заходит в голосовой канал
Порядок диагностики — сверху вниз, каждый шаг отсекает свой слой:
-
bot is readyв логах. Нет — проблема вSTOAT_API_URL/STOAT_BOT_TOKEN. Проверьте, что домен изextra_hostsсовпадает с доменом вSTOAT_API_URL, и что изнутри контейнера он резолвится в хост:docker compose exec mbot node -e "fetch(process.env.STOAT_API_URL).then(r=>console.log(r.status))". -
joining voice channel, но нетvoice connection established. Значитjoin_callотдал токен, а WebSocket до LiveKit не поднялся — смотрите, доступен ли/livekitчерез внешний домен. -
Stoat считает, что бот уже в этом голосовом канале(AlreadyConnected). Зависшее состояние в Redis после падения бота:join_callрегистрирует участника, а выйти он не успел. Ботам API запрещаетforce_disconnect, поэтому чистим вручную — в каталоге инстанса:docker compose exec redis valkey-cli --scan --pattern 'vc:*'docker compose exec redis valkey-cli DEL 'vc:<ID_бота>'docker compose exec redis valkey-cli SREM 'vc_members:<ID_голосового_канала>' '<ID_бота>'Решает дело именно
DEL vc:<ID_бота>— он должен вернуть(integer) 1.SREMчасто возвращает0, это нормально: участники там хранятся в другом виде.Само состояние снимает Stoat, когда бот отключается от LiveKit, поэтому важно, чтобы бот успевал корректно выйти при остановке контейнера — он выходит из каналов первым делом, с отдельным лимитом времени. Если проблема всё же повторяется после каждого перезапуска, смотрите
docker compose logs voice-ingress: снимать состояние по вебхуку от LiveKit — его работа. -
Бот в канале, но звука нет. Это уже медиа-трафик: LiveKit анонсирует клиентам свой адрес из
rtc.node_ip/use_external_ipв/opt/stoat/livekit.ymlи ждёт UDP на 50000-50100. Если анонсируется внешний IP, а роутер не умеет NAT loopback, пакеты от контейнера до него не дойдут. Тогда либо включите hairpin на роутере, либо запустите бота внутри compose-проекта Stoat — пример в deploy/compose.stoat-network.yml.example.
Настройки
Все параметры — в .env.example. Что стоит знать:
DELETE_COMMAND_MESSAGES=true(по умолчанию) — бот удаляет сообщение с командой, чтобы не засорять канал. Для этого роли бота нужно право Manage Messages в настройках сервера (Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно отработает, а в логе будетcould not delete command messageс причиной отказа.REQUIRE_LISTENER=true(по умолчанию) — позвать бота можно только в тот голосовой канал, где вы сами сидите: музыка идёт за слушателем, отправить бота «куда-то ещё» из панели нельзя. Поставьтеfalse, если хотите выбирать канал вручную.REQUIRE_DJ_ROLE=true— управлять смогут только владелец сервера, обладателиManageServerи роли изDJ_ROLE_NAME. По умолчаниюfalse: играть может любой участник сервера.MAX_PLAYLIST_TRACKS— сколько треков максимум добавит одна ссылка на плейлист или микс (по умолчанию 100). Любая ссылка сv=— в том числе скопированная из открытого миксаwatch?v=…&list=RD…— добавляет только сам трек: обычно имеют в виду именно его. Чтобы добавить весь список, вставьте ссылку вида/playlist?list=….EMPTY_TIMEOUT_SECONDS— через сколько секунд после ухода последнего человека бот покидает голосовой канал (по умолчанию 120,0— не выходить никогда). Пустая очередь поводом уйти не считается: пока в канале кто-то есть, бот ждёт следующий трек.LOCAL_MEDIA_DIR— примонтируйте том с музыкой и укажите путь внутри контейнера, тогда заработаютlocal:и поиск по медиатеке.YTDLP_COOKIES— путь кcookies.txt, см. раздел ниже.YTDLP_EXTRACTOR_ARGS— дополнительные--extractor-argsчерез;.
Поиск иногда не находит то, что видно в браузере
YouTube может вернуть пустой список там, где в залогиненном браузере результаты есть: для программного доступа применяется фильтрация (ограниченный режим), причём молча — yt-dlp завершается успешно и без единого сообщения, куки не помогают. Признак: тот же запрос без «спорного» слова находится нормально.
Что делает бот: если YouTube ничего не отдал, поиск автоматически повторяется в SoundCloud, а если пусто и там — сообщает об этом прямо, а не показывает пустой список. Надёжный обход — вставить прямую ссылку на трек: по ссылке фильтр не применяется.
Обновление yt-dlp
YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в
логах — ERROR: [youtube] ...: The page needs to be reloaded или подобное. Лечится обновлением
yt-dlp: версия зашита в Dockerfile как ARG YTDLP_VERSION.
cd /opt/stoat-mbot && git pull && docker compose up -d --build
Если свежая версия вышла, а обновления репозитория ещё нет — можно указать её сразу:
docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compose up -d
Актуальный тег — на странице релизов yt-dlp.
Видео: клип как демонстрация экрана
VIDEO_ENABLED=true разрешает боту публиковать картинку клипа отдельной дорожкой (screen
share), которую видно в голосовом канале. Само включение — тумблер 📺 видео в панели или
команда !video on|off, и по умолчанию оно выключено: видео стоит дороже звука, поэтому платить
за него нужно осознанно. Переключение применяется со следующего трека — менять дорожки на
лету значило бы прервать текущий. При VIDEO_ENABLED=false тумблер в панели не показывается
вовсе, а !video отвечает, что видео выключено в настройках.
VIDEO_ENABLED=true
VIDEO_WIDTH=640
VIDEO_HEIGHT=360
VIDEO_FPS=24
VIDEO_SOURCE=camera
VIDEO_SYNC_OFFSET_MS=0
Что нужно на стороне Stoat: у роли бота — право Video в канале, а на инстансе включённое
видео (VIDEO_ENABLED при генерации конфига, он же video_resolution в Revolt.toml). Токен
на вход в звонок выдаёт разрешение публиковать screen_share только при обоих условиях; иначе
бот сообщит в чат, что видео недоступно, и продолжит играть звук.
Как это устроено и почему так:
- Только YouTube. Для остальных источников картинки нет, трек играет звуком.
- Два способа получить картинку. Если YouTube отдаёт прогрессивный формат (один файл со звуком и видео) — используется он: одна загрузка, один декодер. Такие форматы встречаются всё реже, поэтому есть запасной путь: видео и звук качаются двумя процессами параллельно и сводятся одним ffmpeg по таймкодам. Склейка средствами yt-dlp не годится — он скачивает оба потока целиком, прежде чем выдать первый байт; прямые ссылки на CDN тоже: YouTube подвешивает их для сторонних клиентов.
- Один процесс ffmpeg, два выхода: PCM для голосовой дорожки и сырые I420-кадры для видео.
Темп задаёт
-re, иначе кадры улетали бы вперёд звука и съедали память — распакованный кадр 720p весит 1.4 МБ. - Кодек важнее разрешения. YouTube по умолчанию отдаёт AV1, а его программное декодирование не вытягивает реальное время на слабом сервере: картинка дёргается и уползает от звука. Поэтому запрашивается сначала H.264, затем VP9, и только потом что придётся.
- Размер — это рамка, а не жёсткий кадр.
VIDEO_WIDTH/VIDEO_HEIGHTзадают ограничение, в которое кадр вписывается с сохранением пропорций; чёрные поля не добавляются, их при необходимости рисует сам клиент. - Где показывается.
VIDEO_SOURCE=camera(по умолчанию) — картинка внутри плитки бота;screen— отдельной плиткой, как демонстрация экрана. - Синхронизация. Звук и картинка публикуются двумя дорожками, поэтому совпадение зависит от
того, успевает ли сервер декодировать в реальном времени. Если картинка стабильно
опережает или отстаёт, подстройте
VIDEO_SYNC_OFFSET_MS(плюс задерживает видео). - Цена. Кодирование видео ложится на CPU сервера и держится всё время трека, в отличие от
почти бесплатного звука. 360p по умолчанию выбран из осторожности; поднимайте, если машина
тянет, и снижайте
VIDEO_FPS, если нагрузка мешает. - Живые трансляции и не-YouTube источники играют звуком, как раньше.
Как поднять качество
Три ручки, в порядке влияния на результат:
VIDEO_WIDTH=854
VIDEO_HEIGHT=480
VIDEO_FPS=30
VIDEO_BITRATE_KBPS=1500
VIDEO_QUEUE_MB=96
- Размер кадра.
VIDEO_WIDTH/VIDEO_HEIGHT— рамка; исходник скачивается ровно под неё, ближайшей стандартной ступенью, так что лишнего декодирования не будет. - Битрейт. Сам по себе размер кадра резкости не даёт: при
0LiveKit выбирает осторожное значение, и 720p может выглядеть хуже, чем 480p с хорошим битрейтом. Ориентиры: 360p ≈ 800, 480p ≈ 1500, 720p ≈ 2500–3500 кбит/с. - Частота кадров. 24 хватает для клипов, 30 заметно в динамике и стоит примерно на четверть дороже по процессору.
Что важно понимать про цену: переход с 360p на 720p — это вчетверо больше работы и на
декодировании, и на кодировании, и вчетверо больше памяти под очередь кадров (VIDEO_QUEUE_MB:
кадр 720p весит 1.4 МБ, а ждать своей секунды звука ему приходится несколько секунд).
Поднимайте по одной ступени и смотрите на строку video sync в логе:
resyncsрастёт (больше одного-двух за трек) — сервер не успевает декодировать, шаг назад;droppedрастёт при нулевыхresyncs— не хватаетVIDEO_QUEUE_MB;- обе нули,
aheadByстабилен — запас есть, можно пробовать следующую ступень.
VIDEO_CODEC трогайте в последнюю очередь: h264 разгружает процессор, vp9 и av1 дают
лучшую картинку на том же битрейте, но кодируются дороже — на слабой машине это обычно
проигрыш.
Прокси, когда YouTube недоступен
YTDLP_PROXY пропускает через прокси все обращения yt-dlp — поиск, метаданные и сам
аудиопоток, и для YouTube, и для SoundCloud:
YTDLP_PROXY=socks5h://127.0.0.1:1080
Схемы: http://, https://, socks5://, socks5h://; можно с логином и паролем
(socks5h://user:pass@host:1080). socks5h резолвит DNS на стороне прокси — обычно нужен
именно он, иначе имена доменов всё равно уходят в локальную сеть. При старте бот пишет в лог,
через какой прокси работает, скрывая учётные данные.
Два подводных камня:
-
127.0.0.1внутри контейнера — это сам контейнер. Если Psiphon или ваш SOCKS запущен на хосте, укажитеsocks5h://host.docker.internal:1080(эта запись вextra_hostsуже есть). Бот проверяет прокси при старте и пишет в лог понятную причину, если тот недоступен. -
Прокси в соседнем контейнере — подключайтесь по имени, а не через хост. Если прокси запущен в другом compose-проекте и публикует порт как
127.0.0.1:1080, из контейнера бота он недоступен вообще: публикация на loopback видна только самому хосту. Заведите общую сеть — готовый пример в deploy/compose.proxy.yml.example:docker network ls | grep psiphon cp deploy/compose.proxy.yml.example compose.override.ymlзатем в
.env—YTDLP_PROXY=socks5h://psiphon:1080(имя контейнера прокси) и, если сеть называется иначе,PROXY_NETWORK=<имя>. Файлcompose.override.ymlв.gitignore, так что обновления с ним не конфликтуют. -
Прокси должен слушать интерфейс, видимый контейнеру. Даже с
host.docker.internalсоединение упрётся вECONNREFUSED, если клиент забинден только на loopback. Проверить:ss -lntp | grep 1080Если там
127.0.0.1:1080, разрешите прослушивание всех интерфейсов (в конфиге psiphon-tunnel-core за это отвечаетListenInterface) и закройте порт снаружи файрволом. Альтернатива без правки прокси — добавить сервисуnetwork_mode: host, тогда127.0.0.1:1080работает как есть; но при этом перестают действоватьportsиextra_hosts, то естьSTOAT_DOMAINснова начнёт резолвиться публично, а панель нужно будет привязать кHOST=127.0.0.1. -
ffmpeg не умеет SOCKS. Поэтому при заданном прокси аудио всегда идёт через yt-dlp, а не напрямую из CDN — трафик не утекает мимо прокси. Побочный эффект: перемотка становится медленнее, так как позиция отыгрывается декодированием, а не HTTP-запросом с диапазоном. Прямые ссылки и интернет-радио ffmpeg скачивает сам, и там прокси применяется только для схем
http://иhttps://.
«Sign in to confirm you're not a bot»
Три причины, по которым YouTube так отвечает, в порядке частоты:
-
Нет JS-рантайма. Современный yt-dlp обязан исполнять JavaScript плеера YouTube; без этого извлечение деградирует и запросы выглядят как ботовые. В образе уже есть Node, и бот включает его флагом
--js-runtimes node(YTDLP_JS_RUNTIME). При старте в логе видно, что рантайм подхвачен:"jsRuntime":"node". Если тамnull— рантайм не определился, будет отдельное предупреждение. -
Нет или протухли cookies — см. раздел ниже. Проверить файл прямо в контейнере:
docker compose exec mbot yt-dlp --cookies /data/cookies.txt --simulate https://www.youtube.com/watch?v=dQw4w9WgXcQ -
Подозрительный IP. Выходные узлы Psiphon и прочих публичных прокси YouTube знает и проверяет чаще. Если без прокси скачивание работало, а с ним пошли бот-проверки — оставьте прокси только поиску:
YTDLP_PROXY_SCOPE=searchТогда через прокси идут поиск и метаданные (ради обхода фильтрации выдачи), а аудиопоток качается напрямую. Второй путь — cookies, экспортированные из браузера, работающего через тот же прокси, чтобы сессия не выглядела прыгающей между странами.
Учётка YouTube (cookies)
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
это cookies.txt. С ним открываются видео с возрастным ограничением, приватные и «только для
участников», а также снимается Sign in to confirm you're not a bot, которое YouTube любит
показывать серверным IP.
Заводить лучше отдельный (одноразовый) аккаунт — за автоматизацию YouTube может его заблокировать, терять основной незачем.
- Откройте приватное окно браузера и войдите в YouTube этим аккаунтом.
- Экспортируйте куки для
youtube.comрасширением в формате Netscape (Get cookies.txt LOCALLYи аналоги) — либо, если yt-dlp стоит локально:yt-dlp --cookies-from-browser chrome --cookies cookies.txt. - Не закрывая приватное окно, выйдите из аккаунта в нём (Log out) и только потом закройте окно. Так YouTube не отзовёт сессию, к которой привязаны выгруженные куки.
- Положите файл в
/opt/stoat-mbot/data/cookies.txtи убедитесь, что он писабельный для uid 1000: yt-dlp перезаписывает файл после каждого запуска, сохраняя обновлённые куки. Без права на запись сессия быстро протухнет. - В
.env:YTDLP_COOKIES=/data/cookies.txt, затемdocker compose up -d.
В логах при старте появится using YouTube cookies; если файла нет или он только на чтение —
будет предупреждение с указанием причины.
Куки живут не вечно (обычно недели): когда в логах снова полезут ошибки авторизации, повторите
экспорт. Если YouTube упирается именно в бот-детект, попробуйте дополнительно
YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari.
Разработка
npm install && npm --prefix web install
cp .env.example .env # STOAT_API_URL можно указать публичный адрес инстанса
npm run dev # бот + API на :3005
npm run web:dev # панель на :5180 с проксированием на :3005
Проверки: npm run typecheck, npm run build, npm --prefix web run build.
Локально нужен yt-dlp в PATH (или укажите YTDLP_PATH); ffmpeg приезжает с ffmpeg-static.
Ограничения
revoice.js— сторонняя библиотека без ретраев на разрыв LiveKit-соединения; при падении войса бот выходит из канала, повторный!joinвосстанавливает работу.- Перемотка для YouTube/SoundCloud перезапускает поток с нужной позиции (одна лишняя обращение к yt-dlp), для локальных файлов и прямых ссылок — мгновенная.
- Скачивание с YouTube формально противоречит его ToS; используйте на своё усмотрение, для «чистого» сценария есть SoundCloud, прямые ссылки и локальная медиатека.
Лицензия
MIT — см. LICENSE.