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:
co-authored by
Claude Opus 5
parent
823a9f1565
commit
315760e076
+51
-48
@@ -1,48 +1,51 @@
|
|||||||
# --- panel bundle -----------------------------------------------------------
|
# --- panel bundle -----------------------------------------------------------
|
||||||
FROM node:22-bookworm-slim AS web
|
FROM node:22-bookworm-slim AS web
|
||||||
WORKDIR /app/web
|
WORKDIR /app/web
|
||||||
COPY web/package.json web/package-lock.json* ./
|
COPY web/package.json web/package-lock.json* ./
|
||||||
RUN npm install --no-audit --no-fund
|
RUN npm install --no-audit --no-fund
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# --- server dependencies ----------------------------------------------------
|
# --- server dependencies ----------------------------------------------------
|
||||||
# glibc image on purpose: @livekit/rtc-node ships prebuilt glibc binaries.
|
# glibc image on purpose: @livekit/rtc-node ships prebuilt glibc binaries.
|
||||||
FROM node:22-bookworm-slim AS deps
|
FROM node:22-bookworm-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install --omit=dev --no-audit --no-fund
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-bookworm-slim AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* tsconfig.json ./
|
COPY package.json package-lock.json* tsconfig.json ./
|
||||||
RUN npm install --no-audit --no-fund
|
RUN npm install --no-audit --no-fund
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# --- runtime ----------------------------------------------------------------
|
# --- runtime ----------------------------------------------------------------
|
||||||
FROM node:22-bookworm-slim AS runtime
|
FROM node:22-bookworm-slim AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# yt-dlp keeps its cache under $HOME; /app is not writable for the node user.
|
# yt-dlp keeps its cache under $HOME; /app is not writable for the node user.
|
||||||
ENV HOME=/tmp
|
ENV HOME=/tmp
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# yt-dlp_linux is a self-contained binary, so no Python runtime is needed.
|
# yt-dlp_linux is a self-contained binary, so no Python runtime is needed.
|
||||||
ARG YTDLP_VERSION=2025.08.20
|
# YouTube breaks extractors regularly, so keep this current: rebuilding with
|
||||||
RUN apt-get update \
|
# --build-arg YTDLP_VERSION=<tag> (or bumping this default) is the usual fix for
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
# "The page needs to be reloaded" and similar extraction errors.
|
||||||
&& curl -fsSL "https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_VERSION}/yt-dlp_linux" -o /usr/local/bin/yt-dlp \
|
ARG YTDLP_VERSION=2026.08.19
|
||||||
&& chmod +x /usr/local/bin/yt-dlp \
|
RUN apt-get update \
|
||||||
&& yt-dlp --version \
|
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
&& apt-get purge -y curl \
|
&& curl -fsSL "https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_VERSION}/yt-dlp_linux" -o /usr/local/bin/yt-dlp \
|
||||||
&& apt-get autoremove -y \
|
&& chmod +x /usr/local/bin/yt-dlp \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& yt-dlp --version \
|
||||||
|
&& apt-get purge -y curl \
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
&& apt-get autoremove -y \
|
||||||
COPY --from=build /app/dist ./dist
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=web /app/web/dist ./web/dist
|
|
||||||
COPY package.json ./
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
USER node
|
COPY --from=web /app/web/dist ./web/dist
|
||||||
EXPOSE 3005
|
COPY package.json ./
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 3005
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
|
|||||||
@@ -1,241 +1,259 @@
|
|||||||
# stoat-mbot
|
# stoat-mbot
|
||||||
|
|
||||||
Музыкальный бот для self-hosted [Stoat](https://github.com/stoatchat/self-hosted) с веб-панелью:
|
Музыкальный бот для self-hosted [Stoat](https://github.com/stoatchat/self-hosted) с веб-панелью:
|
||||||
поиск и воспроизведение в голосовых каналах, очередь, перемотка, громкость — из чата и из браузера,
|
поиск и воспроизведение в голосовых каналах, очередь, перемотка, громкость — из чата и из браузера,
|
||||||
состояние синхронизировано в обе стороны через WebSocket.
|
состояние синхронизировано в обе стороны через WebSocket.
|
||||||
|
|
||||||
**Источники:** YouTube, SoundCloud, прямые ссылки и интернет-радио, локальная медиатека (опционально).
|
**Источники:** YouTube, SoundCloud, прямые ссылки и интернет-радио, локальная медиатека (опционально).
|
||||||
**Голос:** LiveKit — тот же, что и в вашем инстансе (`revoice.js` + `@livekit/rtc-node`), звук готовит `ffmpeg`.
|
**Голос:** LiveKit — тот же, что и в вашем инстансе (`revoice.js` + `@livekit/rtc-node`), звук готовит `ffmpeg`.
|
||||||
**Вход в панель:** учётными записями вашего же Stoat (пароль уходит прямо в `/auth/session/login`
|
**Вход в панель:** учётными записями вашего же Stoat (пароль уходит прямо в `/auth/session/login`
|
||||||
вашего инстанса, бот его не хранит) либо одноразовой ссылкой по команде `!panel`.
|
вашего инстанса, бот его не хранит) либо одноразовой ссылкой по команде `!panel`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Как это устроено
|
## Как это устроено
|
||||||
|
|
||||||
```
|
```
|
||||||
Stoat (чат) ──messageCreate──► bot/commands ─┐
|
Stoat (чат) ──messageCreate──► bot/commands ─┐
|
||||||
├──► MusicManager ──► GuildPlayer ──► LiveKit
|
├──► MusicManager ──► GuildPlayer ──► LiveKit
|
||||||
Браузер ──REST + WebSocket──► api/server ───┘ (очередь, (ffmpeg,
|
Браузер ──REST + WebSocket──► api/server ───┘ (очередь, (ffmpeg,
|
||||||
громкость, yt-dlp)
|
громкость, yt-dlp)
|
||||||
повтор)
|
повтор)
|
||||||
```
|
```
|
||||||
|
|
||||||
Чат-команды и панель дергают один и тот же `MusicManager`, поэтому «нажал в браузере — увидел в чате»
|
Чат-команды и панель дергают один и тот же `MusicManager`, поэтому «нажал в браузере — увидел в чате»
|
||||||
работает без рассинхрона. Каждое действие панели повторно проверяет членство и права в Stoat,
|
работает без рассинхрона. Каждое действие панели повторно проверяет членство и права в Stoat,
|
||||||
так что доступ живёт в ролях Stoat, а не в отдельной базе бота.
|
так что доступ живёт в ролях Stoat, а не в отдельной базе бота.
|
||||||
|
|
||||||
| Слой | Файлы |
|
| Слой | Файлы |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Источники и yt-dlp | [src/sources](src/sources) |
|
| Источники и yt-dlp | [src/sources](src/sources) |
|
||||||
| Плеер и очередь | [src/core/player.ts](src/core/player.ts), [src/core/manager.ts](src/core/manager.ts) |
|
| Плеер и очередь | [src/core/player.ts](src/core/player.ts), [src/core/manager.ts](src/core/manager.ts) |
|
||||||
| Чат-бот | [src/bot](src/bot) |
|
| Чат-бот | [src/bot](src/bot) |
|
||||||
| REST + WebSocket | [src/api/server.ts](src/api/server.ts) |
|
| REST + WebSocket | [src/api/server.ts](src/api/server.ts) |
|
||||||
| Веб-панель (React) | [web/src](web/src) |
|
| Веб-панель (React) | [web/src](web/src) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Установка рядом со Stoat
|
## Установка рядом со Stoat
|
||||||
|
|
||||||
Предполагается раскладка `/opt/stoat` (инстанс) и `/opt/stoat-mbot` (этот репозиторий).
|
Предполагается раскладка `/opt/stoat` (инстанс) и `/opt/stoat-mbot` (этот репозиторий).
|
||||||
|
|
||||||
### 1. Клонировать репозиторий
|
### 1. Клонировать репозиторий
|
||||||
|
|
||||||
`/opt` принадлежит root, поэтому клонируем под `sudo` и сразу возвращаем владение себе:
|
`/opt` принадлежит root, поэтому клонируем под `sudo` и сразу возвращаем владение себе:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo git clone https://gitea.hsrv.site/mrleo1nid/stoat-mbot.git /opt/stoat-mbot
|
sudo git clone https://gitea.hsrv.site/mrleo1nid/stoat-mbot.git /opt/stoat-mbot
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo chown -R $USER:$USER /opt/stoat-mbot
|
sudo chown -R $USER:$USER /opt/stoat-mbot
|
||||||
```
|
```
|
||||||
|
|
||||||
`chown` здесь не косметика: дальше вы правите `.env` и `compose.yml`, а bind-mount `./data`
|
`chown` здесь не косметика: дальше вы правите `.env` и `compose.yml`, а bind-mount `./data`
|
||||||
иначе создастся от root. Контейнер работает под пользователем `node` (uid 1000) — если ваш
|
иначе создастся от root. Контейнер работает под пользователем `node` (uid 1000) — если ваш
|
||||||
пользователь тоже uid 1000 (`id -u`), права на `data/` совпадут и yt-dlp сможет туда писать.
|
пользователь тоже uid 1000 (`id -u`), права на `data/` совпадут и yt-dlp сможет туда писать.
|
||||||
|
|
||||||
### 2. Создать бота в Stoat
|
### 2. Создать бота в Stoat
|
||||||
|
|
||||||
Settings → **My Bots** → создать бота → скопировать токен → пригласить бота на сервер.
|
Settings → **My Bots** → создать бота → скопировать токен → пригласить бота на сервер.
|
||||||
Боту нужны права: читать сообщения, писать сообщения и подключаться к голосовым каналам.
|
Боту нужны права: читать сообщения, писать сообщения и подключаться к голосовым каналам.
|
||||||
|
|
||||||
### 3. Заполнить `.env`
|
### 3. Заполнить `.env`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/stoat-mbot
|
cd /opt/stoat-mbot
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
openssl rand -hex 32 # → JWT_SECRET
|
openssl rand -hex 32 # → JWT_SECRET
|
||||||
```
|
```
|
||||||
|
|
||||||
Минимум, что нужно указать:
|
Минимум, что нужно указать:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
STOAT_API_URL=https://stoat.example.com/api
|
STOAT_API_URL=https://stoat.example.com/api
|
||||||
STOAT_BOT_TOKEN=<токен бота>
|
STOAT_BOT_TOKEN=<токен бота>
|
||||||
PUBLIC_URL=https://music.example.com
|
PUBLIC_URL=https://music.example.com
|
||||||
JWT_SECRET=<случайные 32 байта>
|
JWT_SECRET=<случайные 32 байта>
|
||||||
```
|
```
|
||||||
|
|
||||||
`STOAT_API_URL` указывается публичный (`https://домен/api`): бот ведёт себя как обычный клиент —
|
`STOAT_API_URL` указывается публичный (`https://домен/api`): бот ведёт себя как обычный клиент —
|
||||||
ходит в API, gateway и LiveKit через тот же внешний прокси с валидным TLS, что и браузеры.
|
ходит в API, gateway и LiveKit через тот же внешний прокси с валидным TLS, что и браузеры.
|
||||||
|
|
||||||
### 4. Указать домен инстанса в `compose.yml`
|
### 4. Указать домен инстанса в `compose.yml`
|
||||||
|
|
||||||
Бот запускается **отдельным compose-проектом** и с внутренним Caddy инстанса никак не связан.
|
Бот запускается **отдельным compose-проектом** и с внутренним Caddy инстанса никак не связан.
|
||||||
В [compose.yml](compose.yml) поправьте одну строку — домен вашего Stoat:
|
В [compose.yml](compose.yml) поправьте одну строку — домен вашего Stoat:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "chat.example.com:host-gateway"
|
- "chat.example.com:host-gateway"
|
||||||
```
|
```
|
||||||
|
|
||||||
Эта запись заставляет контейнер резолвить домен инстанса в сам хост, где внешний Caddy держит
|
Эта запись заставляет контейнер резолвить домен инстанса в сам хост, где внешний Caddy держит
|
||||||
443 с валидным сертификатом. Так бот не зависит от NAT loopback на роутере.
|
443 с валидным сертификатом. Так бот не зависит от NAT loopback на роутере.
|
||||||
|
|
||||||
### 5. Повесить панель на внешний Caddy
|
### 5. Повесить панель на внешний Caddy
|
||||||
|
|
||||||
Порт панели публикуется только на локальный интерфейс (`127.0.0.1:3005`), домен выдаёт внешний
|
Порт панели публикуется только на локальный интерфейс (`127.0.0.1:3005`), домен выдаёт внешний
|
||||||
Caddy хоста — в `/etc/caddy/Caddyfile` ([deploy/Caddyfile.snippet](deploy/Caddyfile.snippet)):
|
Caddy хоста — в `/etc/caddy/Caddyfile` ([deploy/Caddyfile.snippet](deploy/Caddyfile.snippet)):
|
||||||
|
|
||||||
```caddyfile
|
```caddyfile
|
||||||
music.example.com {
|
music.example.com {
|
||||||
reverse_proxy 127.0.0.1:3005
|
reverse_proxy 127.0.0.1:3005
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Затем `sudo systemctl reload caddy`. WebSocket проксируется автоматически.
|
Затем `sudo systemctl reload caddy`. WebSocket проксируется автоматически.
|
||||||
|
|
||||||
### 6. Запустить
|
### 6. Запустить
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
В логах должно появиться `yt-dlp detected`, `bot is ready` и `panel is listening`.
|
В логах должно появиться `yt-dlp detected`, `bot is ready` и `panel is listening`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Использование
|
## Использование
|
||||||
|
|
||||||
В чате (префикс по умолчанию `!`):
|
В чате (префикс по умолчанию `!`):
|
||||||
|
|
||||||
| Команда | Что делает |
|
| Команда | Что делает |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `!play <ссылка или название>` | добавить трек/плейлист в очередь |
|
| `!play <ссылка или название>` | добавить трек/плейлист в очередь |
|
||||||
| `!playnext`, `!playnow` | следующим / немедленно |
|
| `!playnext`, `!playnow` | следующим / немедленно |
|
||||||
| `!search <запрос>` → `!pick <n>` | поиск с выбором из списка |
|
| `!search <запрос>` → `!pick <n>` | поиск с выбором из списка |
|
||||||
| `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением |
|
| `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением |
|
||||||
| `!queue [страница]`, `!nowplaying` | очередь и текущий трек |
|
| `!queue [страница]`, `!nowplaying` | очередь и текущий трек |
|
||||||
| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок |
|
| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок |
|
||||||
| `!remove <n>`, `!clear`, `!seek 1:23` | правка очереди и перемотка |
|
| `!remove <n>`, `!clear`, `!seek 1:23` | правка очереди и перемотка |
|
||||||
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
|
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
|
||||||
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
|
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
|
||||||
| `!help` | список команд |
|
| `!help` | список команд |
|
||||||
|
|
||||||
Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека.
|
Префиксы поиска: `sc:` — SoundCloud, `yt:` — YouTube, `local:` — локальная медиатека.
|
||||||
|
|
||||||
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
|
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
|
||||||
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Если бот не заходит в голосовой канал
|
## Если бот не заходит в голосовой канал
|
||||||
|
|
||||||
Порядок диагностики — сверху вниз, каждый шаг отсекает свой слой:
|
Порядок диагностики — сверху вниз, каждый шаг отсекает свой слой:
|
||||||
|
|
||||||
1. **`bot is ready` в логах.** Нет — проблема в `STOAT_API_URL`/`STOAT_BOT_TOKEN`. Проверьте, что
|
1. **`bot is ready` в логах.** Нет — проблема в `STOAT_API_URL`/`STOAT_BOT_TOKEN`. Проверьте, что
|
||||||
домен из `extra_hosts` совпадает с доменом в `STOAT_API_URL`, и что изнутри контейнера он
|
домен из `extra_hosts` совпадает с доменом в `STOAT_API_URL`, и что изнутри контейнера он
|
||||||
резолвится в хост: `docker compose exec mbot node -e "fetch(process.env.STOAT_API_URL).then(r=>console.log(r.status))"`.
|
резолвится в хост: `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` отдал
|
2. **`joining voice channel`, но нет `voice connection established`.** Значит `join_call` отдал
|
||||||
токен, а WebSocket до LiveKit не поднялся — смотрите, доступен ли `/livekit` через внешний домен.
|
токен, а WebSocket до LiveKit не поднялся — смотрите, доступен ли `/livekit` через внешний домен.
|
||||||
3. **`Stoat считает, что бот уже в этом голосовом канале` (`AlreadyConnected`).** Зависшее
|
3. **`Stoat считает, что бот уже в этом голосовом канале` (`AlreadyConnected`).** Зависшее
|
||||||
состояние в Redis после падения бота: `join_call` регистрирует участника, а выйти он не успел.
|
состояние в Redis после падения бота: `join_call` регистрирует участника, а выйти он не успел.
|
||||||
Ботам API запрещает `force_disconnect`, поэтому чистим вручную — в каталоге инстанса:
|
Ботам API запрещает `force_disconnect`, поэтому чистим вручную — в каталоге инстанса:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec redis valkey-cli --scan --pattern 'vc:*'
|
docker compose exec redis valkey-cli --scan --pattern 'vc:*'
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec redis valkey-cli DEL 'vc:<ID_бота>'
|
docker compose exec redis valkey-cli DEL 'vc:<ID_бота>'
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec redis valkey-cli SREM 'vc_members:<ID_голосового_канала>' '<ID_бота>'
|
docker compose exec redis valkey-cli SREM 'vc_members:<ID_голосового_канала>' '<ID_бота>'
|
||||||
```
|
```
|
||||||
|
|
||||||
В норме состояние снимает `voice-ingress` по вебхуку от LiveKit — если ситуация повторяется
|
В норме состояние снимает `voice-ingress` по вебхуку от LiveKit — если ситуация повторяется
|
||||||
после каждого перезапуска, смотрите `docker compose logs voice-ingress`.
|
после каждого перезапуска, смотрите `docker compose logs voice-ingress`.
|
||||||
4. **Бот в канале, но звука нет.** Это уже медиа-трафик: LiveKit анонсирует клиентам свой адрес
|
4. **Бот в канале, но звука нет.** Это уже медиа-трафик: LiveKit анонсирует клиентам свой адрес
|
||||||
из `rtc.node_ip` / `use_external_ip` в `/opt/stoat/livekit.yml` и ждёт UDP на 50000-50100.
|
из `rtc.node_ip` / `use_external_ip` в `/opt/stoat/livekit.yml` и ждёт UDP на 50000-50100.
|
||||||
Если анонсируется внешний IP, а роутер не умеет NAT loopback, пакеты от контейнера до него не
|
Если анонсируется внешний IP, а роутер не умеет NAT loopback, пакеты от контейнера до него не
|
||||||
дойдут. Тогда либо включите hairpin на роутере, либо запустите бота внутри compose-проекта
|
дойдут. Тогда либо включите hairpin на роутере, либо запустите бота внутри compose-проекта
|
||||||
Stoat — пример в [deploy/compose.stoat-network.yml.example](deploy/compose.stoat-network.yml.example).
|
Stoat — пример в [deploy/compose.stoat-network.yml.example](deploy/compose.stoat-network.yml.example).
|
||||||
|
|
||||||
## Настройки
|
## Настройки
|
||||||
|
|
||||||
Все параметры — в [.env.example](.env.example). Что стоит знать:
|
Все параметры — в [.env.example](.env.example). Что стоит знать:
|
||||||
|
|
||||||
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
|
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
|
||||||
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает,
|
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает,
|
||||||
а неудачное удаление уйдёт в лог на уровне `debug`.
|
а неудачное удаление уйдёт в лог на уровне `debug`.
|
||||||
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
||||||
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
||||||
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
||||||
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
||||||
тогда заработают `local:` и поиск по медиатеке.
|
тогда заработают `local:` и поиск по медиатеке.
|
||||||
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
|
- `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
|
||||||
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
|
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
|
||||||
|
|
||||||
## Учётка YouTube (cookies)
|
## Обновление yt-dlp
|
||||||
|
|
||||||
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
|
YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в
|
||||||
это `cookies.txt`. С ним открываются видео с возрастным ограничением, приватные и «только для
|
логах — `ERROR: [youtube] ...: The page needs to be reloaded` или подобное. Лечится обновлением
|
||||||
участников», а также снимается `Sign in to confirm you're not a bot`, которое YouTube любит
|
yt-dlp: версия зашита в [Dockerfile](Dockerfile) как `ARG YTDLP_VERSION`.
|
||||||
показывать серверным IP.
|
|
||||||
|
```bash
|
||||||
**Заводить лучше отдельный (одноразовый) аккаунт** — за автоматизацию YouTube может его
|
cd /opt/stoat-mbot && git pull && docker compose up -d --build
|
||||||
заблокировать, терять основной незачем.
|
```
|
||||||
|
|
||||||
1. Откройте **приватное окно** браузера и войдите в YouTube этим аккаунтом.
|
Если свежая версия вышла, а обновления репозитория ещё нет — можно указать её сразу:
|
||||||
2. Экспортируйте куки для `youtube.com` расширением в формате Netscape (`Get cookies.txt LOCALLY`
|
|
||||||
и аналоги) — либо, если yt-dlp стоит локально: `yt-dlp --cookies-from-browser chrome --cookies cookies.txt`.
|
```bash
|
||||||
3. **Не закрывая приватное окно, выйдите из аккаунта в нём** (Log out) и только потом закройте
|
docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compose up -d
|
||||||
окно. Так YouTube не отзовёт сессию, к которой привязаны выгруженные куки.
|
```
|
||||||
4. Положите файл в `/opt/stoat-mbot/data/cookies.txt` и убедитесь, что он писабельный для uid 1000:
|
|
||||||
yt-dlp перезаписывает файл после каждого запуска, сохраняя обновлённые куки. Без права на запись
|
Актуальный тег — на [странице релизов yt-dlp](https://github.com/yt-dlp/yt-dlp/releases).
|
||||||
сессия быстро протухнет.
|
|
||||||
5. В `.env`: `YTDLP_COOKIES=/data/cookies.txt`, затем `docker compose up -d`.
|
## Учётка YouTube (cookies)
|
||||||
|
|
||||||
В логах при старте появится `using YouTube cookies`; если файла нет или он только на чтение —
|
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
|
||||||
будет предупреждение с указанием причины.
|
это `cookies.txt`. С ним открываются видео с возрастным ограничением, приватные и «только для
|
||||||
|
участников», а также снимается `Sign in to confirm you're not a bot`, которое YouTube любит
|
||||||
Куки живут не вечно (обычно недели): когда в логах снова полезут ошибки авторизации, повторите
|
показывать серверным IP.
|
||||||
экспорт. Если YouTube упирается именно в бот-детект, попробуйте дополнительно
|
|
||||||
`YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari`.
|
**Заводить лучше отдельный (одноразовый) аккаунт** — за автоматизацию YouTube может его
|
||||||
|
заблокировать, терять основной незачем.
|
||||||
## Разработка
|
|
||||||
|
1. Откройте **приватное окно** браузера и войдите в YouTube этим аккаунтом.
|
||||||
```bash
|
2. Экспортируйте куки для `youtube.com` расширением в формате Netscape (`Get cookies.txt LOCALLY`
|
||||||
npm install && npm --prefix web install
|
и аналоги) — либо, если yt-dlp стоит локально: `yt-dlp --cookies-from-browser chrome --cookies cookies.txt`.
|
||||||
cp .env.example .env # STOAT_API_URL можно указать публичный адрес инстанса
|
3. **Не закрывая приватное окно, выйдите из аккаунта в нём** (Log out) и только потом закройте
|
||||||
npm run dev # бот + API на :3005
|
окно. Так YouTube не отзовёт сессию, к которой привязаны выгруженные куки.
|
||||||
npm run web:dev # панель на :5180 с проксированием на :3005
|
4. Положите файл в `/opt/stoat-mbot/data/cookies.txt` и убедитесь, что он писабельный для uid 1000:
|
||||||
```
|
yt-dlp перезаписывает файл после каждого запуска, сохраняя обновлённые куки. Без права на запись
|
||||||
|
сессия быстро протухнет.
|
||||||
Проверки: `npm run typecheck`, `npm run build`, `npm --prefix web run build`.
|
5. В `.env`: `YTDLP_COOKIES=/data/cookies.txt`, затем `docker compose up -d`.
|
||||||
Локально нужен `yt-dlp` в `PATH` (или укажите `YTDLP_PATH`); `ffmpeg` приезжает с `ffmpeg-static`.
|
|
||||||
|
В логах при старте появится `using YouTube cookies`; если файла нет или он только на чтение —
|
||||||
## Ограничения
|
будет предупреждение с указанием причины.
|
||||||
|
|
||||||
- `revoice.js` — сторонняя библиотека без ретраев на разрыв LiveKit-соединения; при падении войса
|
Куки живут не вечно (обычно недели): когда в логах снова полезут ошибки авторизации, повторите
|
||||||
бот выходит из канала, повторный `!join` восстанавливает работу.
|
экспорт. Если YouTube упирается именно в бот-детект, попробуйте дополнительно
|
||||||
- Перемотка для YouTube/SoundCloud перезапускает поток с нужной позиции (одна лишняя обращение к
|
`YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari`.
|
||||||
yt-dlp), для локальных файлов и прямых ссылок — мгновенная.
|
|
||||||
- Скачивание с YouTube формально противоречит его ToS; используйте на своё усмотрение,
|
## Разработка
|
||||||
для «чистого» сценария есть SoundCloud, прямые ссылки и локальная медиатека.
|
|
||||||
|
```bash
|
||||||
## Лицензия
|
npm install && npm --prefix web install
|
||||||
|
cp .env.example .env # STOAT_API_URL можно указать публичный адрес инстанса
|
||||||
MIT — см. [LICENSE](LICENSE).
|
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).
|
||||||
|
|||||||
@@ -276,6 +276,14 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
// stop() rebuilds the volume transformer, so volume is applied per track.
|
// stop() rebuilds the volume transformer, so volume is applied per track.
|
||||||
media.setVolume(this.volume / 100);
|
media.setVolume(this.volume / 100);
|
||||||
this.startTicker();
|
this.startTicker();
|
||||||
|
|
||||||
|
// A downloader that dies mid-stream just looks like a very short track, so
|
||||||
|
// say why instead of silently moving on.
|
||||||
|
void input.failure?.then((reason) => {
|
||||||
|
if (!reason || this.current?.id !== track.id) return;
|
||||||
|
this.log.warn({ reason, track: track.title }, "source failed while streaming");
|
||||||
|
this.notify(`⚠️ **${track.title}** — источник отдал ошибку: ${reason}`);
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log.warn({ err, track: track.title }, "playback failed");
|
this.log.warn({ err, track: track.title }, "playback failed");
|
||||||
const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка";
|
const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка";
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ export interface PlaybackInput {
|
|||||||
input: string | Readable;
|
input: string | Readable;
|
||||||
inputOptions: string[];
|
inputOptions: string[];
|
||||||
cleanup(): void;
|
cleanup(): void;
|
||||||
|
/** Resolves with a reason if the downloader died on its own, for reporting. */
|
||||||
|
failure?: Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTTP_RESILIENCE = [
|
const HTTP_RESILIENCE = [
|
||||||
@@ -139,5 +141,10 @@ export async function openPlayback(track: Track, seekSeconds = 0): Promise<Playb
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proc = ytdlp.openAudioStream(track.url);
|
const proc = ytdlp.openAudioStream(track.url);
|
||||||
return { input: proc.stream, inputOptions: [], cleanup: () => proc.kill() };
|
return {
|
||||||
|
input: proc.stream,
|
||||||
|
inputOptions: [],
|
||||||
|
cleanup: () => proc.kill(),
|
||||||
|
failure: proc.failure,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -209,6 +209,8 @@ export async function resolveStreamUrl(pageUrl: string): Promise<string> {
|
|||||||
export interface AudioProcess {
|
export interface AudioProcess {
|
||||||
stream: Readable;
|
stream: Readable;
|
||||||
kill(): void;
|
kill(): void;
|
||||||
|
/** Resolves with a reason when the download fails, or null when it was fine. */
|
||||||
|
failure: Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
|
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
|
||||||
@@ -220,19 +222,29 @@ export function openAudioStream(pageUrl: string): AudioProcess {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
let killed = false;
|
||||||
child.stderr.setEncoding("utf8");
|
child.stderr.setEncoding("utf8");
|
||||||
child.stderr.on("data", (chunk: string) => {
|
child.stderr.on("data", (chunk: string) => {
|
||||||
stderr = (stderr + chunk).slice(-2000);
|
stderr = (stderr + chunk).slice(-2000);
|
||||||
});
|
});
|
||||||
child.on("close", (code) => {
|
|
||||||
if (code !== 0 && code !== null && stderr.trim()) {
|
const failure = new Promise<string | null>((resolve) => {
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (killed || code === 0 || code === null) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
|
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
|
||||||
}
|
resolve(firstUsefulError(stderr));
|
||||||
|
});
|
||||||
|
child.on("error", (err: Error) => resolve(err.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stream: child.stdout,
|
stream: child.stdout,
|
||||||
|
failure,
|
||||||
kill: () => {
|
kill: () => {
|
||||||
|
killed = true;
|
||||||
if (child.exitCode === null) child.kill("SIGKILL");
|
if (child.exitCode === null) child.kill("SIGKILL");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user