Three problems visible at once on a running instance: the picture stuttered and drifted behind the sound, it was letterboxed oddly, and it appeared as a separate tile instead of coming from the bot. - YouTube handed us AV1 (format 398). Software-decoding AV1 at 720p does not sustain real time on a small server, which explains both the stutter and the drift; H.264 is now requested first, VP9 second. - The frame was padded into a fixed box, so a clip whose proportions differed got black bars baked in and then more from the client. Size is now a bounding box and the frame keeps the clip's own proportions. - Video was published as a screen share, which every client renders as its own tile. VIDEO_SOURCE=camera (the new default) puts it inside the bot's tile; "screen" keeps the old behaviour. VIDEO_SYNC_OFFSET_MS is there for the residual drift, since audio and video travel as two separately published tracks. Measured loudnorm first to rule it out as the cause of the desync: it adds 0 ms. 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 <запрос> → !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] |
показывать клип как демонстрацию экрана |
!join, !leave |
зайти в ваш голосовой канал / выйти |
!panel |
личная ссылка на веб-панель (действует 10 минут) |
!help |
список команд |
Префиксы поиска: sc: — SoundCloud, yt: — YouTube, local: — локальная медиатека.
В панели то же самое выбирается списком слева от строки поиска; по умолчанию ищет везде сразу,
а префикс в запросе перебивает выбор в списке.
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками, клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
Если бот не заходит в голосовой канал
Порядок диагностики — сверху вниз, каждый шаг отсекает свой слой:
-
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_бота>'В норме состояние снимает
voice-ingressпо вебхуку от LiveKit — если ситуация повторяется после каждого перезапуска, смотритеdocker compose logs voice-ingress. -
Бот в канале, но звука нет. Это уже медиа-трафик: 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 источники играют звуком, как раньше.
Прокси, когда 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.