diff --git a/Dockerfile b/Dockerfile index a9129cb..9db59f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,48 +1,51 @@ -# --- panel bundle ----------------------------------------------------------- -FROM node:22-bookworm-slim AS web -WORKDIR /app/web -COPY web/package.json web/package-lock.json* ./ -RUN npm install --no-audit --no-fund -COPY web/ ./ -RUN npm run build - -# --- server dependencies ---------------------------------------------------- -# glibc image on purpose: @livekit/rtc-node ships prebuilt glibc binaries. -FROM node:22-bookworm-slim AS deps -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm install --omit=dev --no-audit --no-fund - -FROM node:22-bookworm-slim AS build -WORKDIR /app -COPY package.json package-lock.json* tsconfig.json ./ -RUN npm install --no-audit --no-fund -COPY src/ ./src/ -RUN npm run build - -# --- runtime ---------------------------------------------------------------- -FROM node:22-bookworm-slim AS runtime -ENV NODE_ENV=production -# yt-dlp keeps its cache under $HOME; /app is not writable for the node user. -ENV HOME=/tmp -WORKDIR /app - -# yt-dlp_linux is a self-contained binary, so no Python runtime is needed. -ARG YTDLP_VERSION=2025.08.20 -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ - && curl -fsSL "https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_VERSION}/yt-dlp_linux" -o /usr/local/bin/yt-dlp \ - && chmod +x /usr/local/bin/yt-dlp \ - && yt-dlp --version \ - && apt-get purge -y curl \ - && apt-get autoremove -y \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=deps /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist -COPY --from=web /app/web/dist ./web/dist -COPY package.json ./ - -USER node -EXPOSE 3005 -CMD ["node", "dist/index.js"] +# --- panel bundle ----------------------------------------------------------- +FROM node:22-bookworm-slim AS web +WORKDIR /app/web +COPY web/package.json web/package-lock.json* ./ +RUN npm install --no-audit --no-fund +COPY web/ ./ +RUN npm run build + +# --- server dependencies ---------------------------------------------------- +# glibc image on purpose: @livekit/rtc-node ships prebuilt glibc binaries. +FROM node:22-bookworm-slim AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev --no-audit --no-fund + +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package.json package-lock.json* tsconfig.json ./ +RUN npm install --no-audit --no-fund +COPY src/ ./src/ +RUN npm run build + +# --- runtime ---------------------------------------------------------------- +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production +# yt-dlp keeps its cache under $HOME; /app is not writable for the node user. +ENV HOME=/tmp +WORKDIR /app + +# yt-dlp_linux is a self-contained binary, so no Python runtime is needed. +# YouTube breaks extractors regularly, so keep this current: rebuilding with +# --build-arg YTDLP_VERSION= (or bumping this default) is the usual fix for +# "The page needs to be reloaded" and similar extraction errors. +ARG YTDLP_VERSION=2026.08.19 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -fsSL "https://github.com/yt-dlp/yt-dlp/releases/download/${YTDLP_VERSION}/yt-dlp_linux" -o /usr/local/bin/yt-dlp \ + && chmod +x /usr/local/bin/yt-dlp \ + && yt-dlp --version \ + && apt-get purge -y curl \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=web /app/web/dist ./web/dist +COPY package.json ./ + +USER node +EXPOSE 3005 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 4505689..c393e2e 100644 --- a/README.md +++ b/README.md @@ -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 ` | поиск с выбором из списка | -| `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением | -| `!queue [страница]`, `!nowplaying` | очередь и текущий трек | -| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок | -| `!remove `, `!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:' - ``` - - ```bash - docker compose exec redis valkey-cli SREM 'vc_members:' '' - ``` - - В норме состояние снимает `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 ` | поиск с выбором из списка | +| `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением | +| `!queue [страница]`, `!nowplaying` | очередь и текущий трек | +| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок | +| `!remove `, `!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:' + ``` + + ```bash + docker compose exec redis valkey-cli SREM 'vc_members:' '' + ``` + + В норме состояние снимает `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). diff --git a/src/core/player.ts b/src/core/player.ts index f881513..169f9bc 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -276,6 +276,14 @@ export class GuildPlayer extends EventEmitter { // stop() rebuilds the volume transformer, so volume is applied per track. media.setVolume(this.volume / 100); 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) { this.log.warn({ err, track: track.title }, "playback failed"); const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка"; diff --git a/src/sources/index.ts b/src/sources/index.ts index 918083f..50d401a 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -110,6 +110,8 @@ export interface PlaybackInput { input: string | Readable; inputOptions: string[]; cleanup(): void; + /** Resolves with a reason if the downloader died on its own, for reporting. */ + failure?: Promise; } const HTTP_RESILIENCE = [ @@ -139,5 +141,10 @@ export async function openPlayback(track: Track, seekSeconds = 0): Promise proc.kill() }; + return { + input: proc.stream, + inputOptions: [], + cleanup: () => proc.kill(), + failure: proc.failure, + }; } diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index f3d7573..432a7dc 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -209,6 +209,8 @@ export async function resolveStreamUrl(pageUrl: string): Promise { export interface AudioProcess { stream: Readable; kill(): void; + /** Resolves with a reason when the download fails, or null when it was fine. */ + failure: Promise; } /** 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 killed = false; child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { stderr = (stderr + chunk).slice(-2000); }); - child.on("close", (code) => { - if (code !== 0 && code !== null && stderr.trim()) { + + const failure = new Promise((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"); - } + resolve(firstUsefulError(stderr)); + }); + child.on("error", (err: Error) => resolve(err.message)); }); return { stream: child.stdout, + failure, kill: () => { + killed = true; if (child.exitCode === null) child.kill("SIGKILL"); }, };