Update bundled yt-dlp and report source failures in chat

The image shipped a year-old yt-dlp, which YouTube now rejects with
"The page needs to be reloaded". Bumped to 2026.08.19 and documented
rebuilding as the standard fix, including how to pass a newer tag
without waiting for a repository update.

A downloader dying mid-stream also looked exactly like a very short
track: ffmpeg saw EOF, the player advanced, and the channel only got
"queue finished". The failure reason now reaches the chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-08 23:45:25 +03:00
co-authored by Claude Opus 5
parent 823a9f1565
commit 315760e076
5 changed files with 341 additions and 293 deletions
+259 -241
View File
@@ -1,241 +1,259 @@
# stoat-mbot
Музыкальный бот для self-hosted [Stoat](https://github.com/stoatchat/self-hosted) с веб-панелью:
поиск и воспроизведение в голосовых каналах, очередь, перемотка, громкость — из чата и из браузера,
состояние синхронизировано в обе стороны через 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/sources) |
| Плеер и очередь | [src/core/player.ts](src/core/player.ts), [src/core/manager.ts](src/core/manager.ts) |
| Чат-бот | [src/bot](src/bot) |
| REST + WebSocket | [src/api/server.ts](src/api/server.ts) |
| Веб-панель (React) | [web/src](web/src) |
---
## Установка рядом со Stoat
Предполагается раскладка `/opt/stoat` (инстанс) и `/opt/stoat-mbot` (этот репозиторий).
### 1. Клонировать репозиторий
`/opt` принадлежит root, поэтому клонируем под `sudo` и сразу возвращаем владение себе:
```bash
sudo git clone https://gitea.hsrv.site/mrleo1nid/stoat-mbot.git /opt/stoat-mbot
```
```bash
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`
```bash
cd /opt/stoat-mbot
cp .env.example .env
openssl rand -hex 32 # → JWT_SECRET
```
Минимум, что нужно указать:
```dotenv
STOAT_API_URL=https://stoat.example.com/api
STOAT_BOT_TOKEN=<токен бота>
PUBLIC_URL=https://music.example.com
JWT_SECRET=<случайные 32 байта>
```
`STOAT_API_URL` указывается публичный (`https://домен/api`): бот ведёт себя как обычный клиент —
ходит в API, gateway и LiveKit через тот же внешний прокси с валидным TLS, что и браузеры.
### 4. Указать домен инстанса в `compose.yml`
Бот запускается **отдельным compose-проектом** и с внутренним Caddy инстанса никак не связан.
В [compose.yml](compose.yml) поправьте одну строку — домен вашего Stoat:
```yaml
extra_hosts:
- "chat.example.com:host-gateway"
```
Эта запись заставляет контейнер резолвить домен инстанса в сам хост, где внешний Caddy держит
443 с валидным сертификатом. Так бот не зависит от NAT loopback на роутере.
### 5. Повесить панель на внешний Caddy
Порт панели публикуется только на локальный интерфейс (`127.0.0.1:3005`), домен выдаёт внешний
Caddy хоста — в `/etc/caddy/Caddyfile` ([deploy/Caddyfile.snippet](deploy/Caddyfile.snippet)):
```caddyfile
music.example.com {
reverse_proxy 127.0.0.1:3005
}
```
Затем `sudo systemctl reload caddy`. WebSocket проксируется автоматически.
### 6. Запустить
```bash
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` | правка очереди и перемотка |
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
| `!help` | список команд |
Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека.
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
---
## Если бот не заходит в голосовой канал
Порядок диагностики — сверху вниз, каждый шаг отсекает свой слой:
1. **`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))"`.
2. **`joining voice channel`, но нет `voice connection established`.** Значит `join_call` отдал
токен, а WebSocket до LiveKit не поднялся — смотрите, доступен ли `/livekit` через внешний домен.
3. **`Stoat считает, что бот уже в этом голосовом канале` (`AlreadyConnected`).** Зависшее
состояние в Redis после падения бота: `join_call` регистрирует участника, а выйти он не успел.
Ботам API запрещает `force_disconnect`, поэтому чистим вручную — в каталоге инстанса:
```bash
docker compose exec redis valkey-cli --scan --pattern 'vc:*'
```
```bash
docker compose exec redis valkey-cli DEL 'vc:<ID_бота>'
```
```bash
docker compose exec redis valkey-cli SREM 'vc_members:<ID_голосового_канала>' '<ID_бота>'
```
В норме состояние снимает `voice-ingress` по вебхуку от LiveKit — если ситуация повторяется
после каждого перезапуска, смотрите `docker compose logs voice-ingress`.
4. **Бот в канале, но звука нет.** Это уже медиа-трафик: 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](deploy/compose.stoat-network.yml.example).
## Настройки
Все параметры — в [.env.example](.env.example). Что стоит знать:
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает,
а неудачное удаление уйдёт в лог на уровне `debug`.
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
тогда заработают `local:` и поиск по медиатеке.
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
## Учётка YouTube (cookies)
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
это `cookies.txt`. С ним открываются видео с возрастным ограничением, приватные и «только для
участников», а также снимается `Sign in to confirm you're not a bot`, которое YouTube любит
показывать серверным IP.
**Заводить лучше отдельный (одноразовый) аккаунт** — за автоматизацию YouTube может его
заблокировать, терять основной незачем.
1. Откройте **приватное окно** браузера и войдите в YouTube этим аккаунтом.
2. Экспортируйте куки для `youtube.com` расширением в формате Netscape (`Get cookies.txt LOCALLY`
и аналоги) — либо, если yt-dlp стоит локально: `yt-dlp --cookies-from-browser chrome --cookies cookies.txt`.
3. **Не закрывая приватное окно, выйдите из аккаунта в нём** (Log out) и только потом закройте
окно. Так YouTube не отзовёт сессию, к которой привязаны выгруженные куки.
4. Положите файл в `/opt/stoat-mbot/data/cookies.txt` и убедитесь, что он писабельный для uid 1000:
yt-dlp перезаписывает файл после каждого запуска, сохраняя обновлённые куки. Без права на запись
сессия быстро протухнет.
5. В `.env`: `YTDLP_COOKIES=/data/cookies.txt`, затем `docker compose up -d`.
В логах при старте появится `using YouTube cookies`; если файла нет или он только на чтение —
будет предупреждение с указанием причины.
Куки живут не вечно (обычно недели): когда в логах снова полезут ошибки авторизации, повторите
экспорт. Если YouTube упирается именно в бот-детект, попробуйте дополнительно
`YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari`.
## Разработка
```bash
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](LICENSE).
# stoat-mbot
Музыкальный бот для self-hosted [Stoat](https://github.com/stoatchat/self-hosted) с веб-панелью:
поиск и воспроизведение в голосовых каналах, очередь, перемотка, громкость — из чата и из браузера,
состояние синхронизировано в обе стороны через 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/sources) |
| Плеер и очередь | [src/core/player.ts](src/core/player.ts), [src/core/manager.ts](src/core/manager.ts) |
| Чат-бот | [src/bot](src/bot) |
| REST + WebSocket | [src/api/server.ts](src/api/server.ts) |
| Веб-панель (React) | [web/src](web/src) |
---
## Установка рядом со Stoat
Предполагается раскладка `/opt/stoat` (инстанс) и `/opt/stoat-mbot` (этот репозиторий).
### 1. Клонировать репозиторий
`/opt` принадлежит root, поэтому клонируем под `sudo` и сразу возвращаем владение себе:
```bash
sudo git clone https://gitea.hsrv.site/mrleo1nid/stoat-mbot.git /opt/stoat-mbot
```
```bash
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`
```bash
cd /opt/stoat-mbot
cp .env.example .env
openssl rand -hex 32 # → JWT_SECRET
```
Минимум, что нужно указать:
```dotenv
STOAT_API_URL=https://stoat.example.com/api
STOAT_BOT_TOKEN=<токен бота>
PUBLIC_URL=https://music.example.com
JWT_SECRET=<случайные 32 байта>
```
`STOAT_API_URL` указывается публичный (`https://домен/api`): бот ведёт себя как обычный клиент —
ходит в API, gateway и LiveKit через тот же внешний прокси с валидным TLS, что и браузеры.
### 4. Указать домен инстанса в `compose.yml`
Бот запускается **отдельным compose-проектом** и с внутренним Caddy инстанса никак не связан.
В [compose.yml](compose.yml) поправьте одну строку — домен вашего Stoat:
```yaml
extra_hosts:
- "chat.example.com:host-gateway"
```
Эта запись заставляет контейнер резолвить домен инстанса в сам хост, где внешний Caddy держит
443 с валидным сертификатом. Так бот не зависит от NAT loopback на роутере.
### 5. Повесить панель на внешний Caddy
Порт панели публикуется только на локальный интерфейс (`127.0.0.1:3005`), домен выдаёт внешний
Caddy хоста — в `/etc/caddy/Caddyfile` ([deploy/Caddyfile.snippet](deploy/Caddyfile.snippet)):
```caddyfile
music.example.com {
reverse_proxy 127.0.0.1:3005
}
```
Затем `sudo systemctl reload caddy`. WebSocket проксируется автоматически.
### 6. Запустить
```bash
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` | правка очереди и перемотка |
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
| `!help` | список команд |
Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека.
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
---
## Если бот не заходит в голосовой канал
Порядок диагностики — сверху вниз, каждый шаг отсекает свой слой:
1. **`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))"`.
2. **`joining voice channel`, но нет `voice connection established`.** Значит `join_call` отдал
токен, а WebSocket до LiveKit не поднялся — смотрите, доступен ли `/livekit` через внешний домен.
3. **`Stoat считает, что бот уже в этом голосовом канале` (`AlreadyConnected`).** Зависшее
состояние в Redis после падения бота: `join_call` регистрирует участника, а выйти он не успел.
Ботам API запрещает `force_disconnect`, поэтому чистим вручную — в каталоге инстанса:
```bash
docker compose exec redis valkey-cli --scan --pattern 'vc:*'
```
```bash
docker compose exec redis valkey-cli DEL 'vc:<ID_бота>'
```
```bash
docker compose exec redis valkey-cli SREM 'vc_members:<ID_голосового_канала>' '<ID_бота>'
```
В норме состояние снимает `voice-ingress` по вебхуку от LiveKit — если ситуация повторяется
после каждого перезапуска, смотрите `docker compose logs voice-ingress`.
4. **Бот в канале, но звука нет.** Это уже медиа-трафик: 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](deploy/compose.stoat-network.yml.example).
## Настройки
Все параметры — в [.env.example](.env.example). Что стоит знать:
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает,
а неудачное удаление уйдёт в лог на уровне `debug`.
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
тогда заработают `local:` и поиск по медиатеке.
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
## Обновление yt-dlp
YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в
логах — `ERROR: [youtube] ...: The page needs to be reloaded` или подобное. Лечится обновлением
yt-dlp: версия зашита в [Dockerfile](Dockerfile) как `ARG YTDLP_VERSION`.
```bash
cd /opt/stoat-mbot && git pull && docker compose up -d --build
```
Если свежая версия вышла, а обновления репозитория ещё нет — можно указать её сразу:
```bash
docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compose up -d
```
Актуальный тег — на [странице релизов yt-dlp](https://github.com/yt-dlp/yt-dlp/releases).
## Учётка YouTube (cookies)
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
это `cookies.txt`. С ним открываются видео с возрастным ограничением, приватные и «только для
участников», а также снимается `Sign in to confirm you're not a bot`, которое YouTube любит
показывать серверным IP.
**Заводить лучше отдельный (одноразовый) аккаунт** — за автоматизацию YouTube может его
заблокировать, терять основной незачем.
1. Откройте **приватное окно** браузера и войдите в YouTube этим аккаунтом.
2. Экспортируйте куки для `youtube.com` расширением в формате Netscape (`Get cookies.txt LOCALLY`
и аналоги) — либо, если yt-dlp стоит локально: `yt-dlp --cookies-from-browser chrome --cookies cookies.txt`.
3. **Не закрывая приватное окно, выйдите из аккаунта в нём** (Log out) и только потом закройте
окно. Так YouTube не отзовёт сессию, к которой привязаны выгруженные куки.
4. Положите файл в `/opt/stoat-mbot/data/cookies.txt` и убедитесь, что он писабельный для uid 1000:
yt-dlp перезаписывает файл после каждого запуска, сохраняя обновлённые куки. Без права на запись
сессия быстро протухнет.
5. В `.env`: `YTDLP_COOKIES=/data/cookies.txt`, затем `docker compose up -d`.
В логах при старте появится `using YouTube cookies`; если файла нет или он только на чтение —
будет предупреждение с указанием причины.
Куки живут не вечно (обычно недели): когда в логах снова полезут ошибки авторизации, повторите
экспорт. Если YouTube упирается именно в бот-детект, попробуйте дополнительно
`YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari`.
## Разработка
```bash
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](LICENSE).