Add music bot for self-hosted Stoat with web control panel
Plays audio into Stoat voice channels over LiveKit and exposes the same player through both chat commands and a browser panel, so the two never drift apart: everything routes through a single MusicManager. - core: per-server GuildPlayer (queue, loop, shuffle, seek, volume, idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg - sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet radio, optional local library with path-traversal guards - bot: 18 chat commands with aliases, plus !panel one-time login links - api: Fastify REST + WebSocket, sessions authenticated against the instance's own /auth/session/login (TOTP supported), permissions re-checked against Stoat membership and roles on every request - web: React panel with search, queue editing, seek and volume - deploy: Dockerfile, compose.override.yml and Caddyfile snippets for dropping the service into an existing /opt/stoat stack Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs (9 API checks). Voice playback itself needs a live instance to test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9d0e9f6bf
commit
a9b7ccdd16
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
web/node_modules
|
||||||
|
dist
|
||||||
|
web/dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# ---------------------------------------------------------------- Stoat ----
|
||||||
|
# Публичный адрес API вашего инстанса (тот же, что в клиенте).
|
||||||
|
# Внутри docker-сети Stoat можно использовать http://api:14702 — но тогда
|
||||||
|
# убедитесь, что LiveKit URL из join_call резолвится изнутри контейнера.
|
||||||
|
STOAT_API_URL=https://stoat.example.com/api
|
||||||
|
|
||||||
|
# Токен бота: Settings → My Bots → создать бота → скопировать токен.
|
||||||
|
STOAT_BOT_TOKEN=
|
||||||
|
|
||||||
|
# Префикс команд в чате.
|
||||||
|
COMMAND_PREFIX=!
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Веб-панель ---
|
||||||
|
PORT=3005
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Адрес, по которому панель открывается в браузере (без слэша в конце).
|
||||||
|
PUBLIC_URL=https://music.example.com
|
||||||
|
|
||||||
|
# Секрет для подписи сессионных cookie. Сгенерируйте: openssl rand -hex 32
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Срок жизни сессии панели, часов.
|
||||||
|
SESSION_TTL_HOURS=168
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Аудио ----
|
||||||
|
# Путь к yt-dlp. В docker-образе он уже установлен.
|
||||||
|
YTDLP_PATH=yt-dlp
|
||||||
|
|
||||||
|
# Необязательно: файл cookies.txt для приватных/возрастных видео.
|
||||||
|
# YTDLP_COOKIES=/data/cookies.txt
|
||||||
|
|
||||||
|
# Необязательно: каталог с локальной медиатекой (смонтируйте том).
|
||||||
|
# LOCAL_MEDIA_DIR=/media/music
|
||||||
|
|
||||||
|
DEFAULT_VOLUME=60
|
||||||
|
MAX_QUEUE_SIZE=500
|
||||||
|
SEARCH_RESULT_LIMIT=10
|
||||||
|
|
||||||
|
# Через сколько секунд простоя бот выходит из голосового канала (0 — никогда).
|
||||||
|
IDLE_TIMEOUT_SECONDS=300
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Доступ ----
|
||||||
|
# false — управлять может любой участник сервера.
|
||||||
|
# true — только владелец, ManageServer или роль DJ_ROLE_NAME.
|
||||||
|
REQUIRE_DJ_ROLE=false
|
||||||
|
DJ_ROLE_NAME=DJ
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- Прочее ---
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NODE_ENV=production
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
web/dist/
|
||||||
|
web/node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
|
*.tsbuildinfo
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# --- 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"]
|
||||||
@@ -1,2 +1,179 @@
|
|||||||
# stoat-mbot
|
# 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. Клонировать репозиторий
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt && git clone https://gitea.hsrv.site/mrleo1nid/stoat-mbot.git stoat-mbot
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`): бот тогда ведёт себя как обычный
|
||||||
|
клиент и получает от `join_call` тот же LiveKit-URL, что и все. Внутренний `http://api:14702` тоже
|
||||||
|
работает, но убедитесь, что LiveKit-URL из ответа резолвится изнутри контейнера.
|
||||||
|
|
||||||
|
### 4. Добавить сервис в `compose.override.yml`
|
||||||
|
|
||||||
|
В `/opt/stoat/compose.override.yml` (готовый пример — [deploy/compose.override.yml.example](deploy/compose.override.yml.example)):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:8880:80"
|
||||||
|
|
||||||
|
mbot:
|
||||||
|
build: ../stoat-mbot
|
||||||
|
restart: always
|
||||||
|
env_file: ../stoat-mbot/.env
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- ../stoat-mbot/data:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервис попадает в тот же compose-проект и ту же сеть, поэтому Caddy видит его как `http://mbot:3005`,
|
||||||
|
а бот ходит в Stoat API по имени `api`.
|
||||||
|
|
||||||
|
### 5. Пробросить панель через Caddy
|
||||||
|
|
||||||
|
В `/opt/stoat/Caddyfile` добавьте блок ([deploy/Caddyfile.snippet](deploy/Caddyfile.snippet)):
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
http://music.example.com {
|
||||||
|
reverse_proxy http://mbot:3005
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Схема `http://` — потому что у вас TLS терминирует внешний прокси (Caddy слушает `127.0.0.1:8880`).
|
||||||
|
WebSocket проксируется автоматически.
|
||||||
|
|
||||||
|
### 6. Запустить
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/stoat && docker compose up -d --build mbot && docker compose logs -f mbot
|
||||||
|
```
|
||||||
|
|
||||||
|
В логах должно появиться `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 перестановка треков стрелками,
|
||||||
|
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Настройки
|
||||||
|
|
||||||
|
Все параметры — в [.env.example](.env.example). Что стоит знать:
|
||||||
|
|
||||||
|
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
|
||||||
|
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
|
||||||
|
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
|
||||||
|
- `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера,
|
||||||
|
тогда заработают `local:` и поиск по медиатеке.
|
||||||
|
- `YTDLP_COOKIES` — путь к `cookies.txt`, если YouTube просит подтверждения возраста или логина.
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```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).
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Добавьте отдельным блоком в /opt/stoat/Caddyfile, рядом с {$HOSTNAME} { ... }.
|
||||||
|
#
|
||||||
|
# У вас Caddy слушает только 80 (127.0.0.1:8880 → 80), а TLS терминирует
|
||||||
|
# внешний прокси на хосте, поэтому используем схему http:// —
|
||||||
|
# так Caddy не будет пытаться сам выпускать сертификат.
|
||||||
|
http://music.example.com {
|
||||||
|
reverse_proxy http://mbot:3005
|
||||||
|
}
|
||||||
|
|
||||||
|
# Если внешнего прокси нет и Caddy сам держит 80/443 — просто:
|
||||||
|
# music.example.com {
|
||||||
|
# reverse_proxy http://mbot:3005
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# WebSocket (/ws) проксируется автоматически, отдельных директив не нужно.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# /opt/stoat/compose.override.yml
|
||||||
|
#
|
||||||
|
# Раскладка:
|
||||||
|
# /opt/stoat — ваш self-hosted Stoat (compose.yml, Caddyfile, ...)
|
||||||
|
# /opt/stoat-mbot — этот репозиторий
|
||||||
|
#
|
||||||
|
# Сервис живёт в том же compose-проекте, поэтому попадает в сеть stoat_default:
|
||||||
|
# Caddy видит его как http://mbot:3005, а бот — Stoat API как http://api:14702.
|
||||||
|
services:
|
||||||
|
# ваш существующий override для Caddy — оставьте как есть
|
||||||
|
caddy:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:8880:80"
|
||||||
|
|
||||||
|
mbot:
|
||||||
|
build: ../stoat-mbot
|
||||||
|
restart: always
|
||||||
|
env_file: ../stoat-mbot/.env
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
# cookies.txt для yt-dlp и/или локальная медиатека
|
||||||
|
- ../stoat-mbot/data:/data
|
||||||
|
# Панель отдаётся через Caddy. Порт наружу нужен, только если панель
|
||||||
|
# проксирует внешний nginx на хосте, минуя Caddy:
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:3005:3005"
|
||||||
Generated
+5964
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "stoat-mbot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Music bot for self-hosted Stoat with a web control panel",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"web:dev": "npm --prefix web run dev",
|
||||||
|
"web:build": "npm --prefix web run build",
|
||||||
|
"test:smoke": "node scripts/smoke-api.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/static": "^8.1.1",
|
||||||
|
"@fastify/websocket": "^11.0.2",
|
||||||
|
"fastify": "^5.2.1",
|
||||||
|
"jose": "^6.0.10",
|
||||||
|
"pino": "^9.6.0",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"revoice.js": "^0.2.1696",
|
||||||
|
"stoat.js": "^7.3.6",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Smoke test: boots the API layer with a stubbed Stoat context and exercises
|
||||||
|
// the auth flow + a couple of routes. No real Stoat instance involved.
|
||||||
|
process.env.STOAT_API_URL = "http://127.0.0.1:9/api";
|
||||||
|
process.env.STOAT_BOT_TOKEN = "test-token";
|
||||||
|
process.env.PUBLIC_URL = "http://127.0.0.1:3199";
|
||||||
|
process.env.JWT_SECRET = "0123456789abcdef0123456789abcdef";
|
||||||
|
process.env.PORT = "3199";
|
||||||
|
process.env.LOG_LEVEL = "warn";
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
|
||||||
|
const { startApiServer } = await import("../dist/api/server.js");
|
||||||
|
const { MusicManager } = await import("../dist/core/manager.js");
|
||||||
|
const { signPanelLink } = await import("../dist/auth/tokens.js");
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
getServerName: () => "Test Server",
|
||||||
|
getVoiceChannel: (id) => ({ id, name: "General" }),
|
||||||
|
listVoiceChannels: () => [{ id: "vc1", name: "General" }],
|
||||||
|
findUserVoiceChannel: () => ({ id: "vc1", name: "General" }),
|
||||||
|
listServersForUser: async () => [{ id: "srv1", name: "Test Server", iconUrl: null }],
|
||||||
|
isMember: async (s, u) => s === "srv1" && u === "user1",
|
||||||
|
canControl: async () => true,
|
||||||
|
sendMessage: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new MusicManager();
|
||||||
|
manager.attachStoat(context);
|
||||||
|
const app = await startApiServer({ manager, context });
|
||||||
|
|
||||||
|
const base = "http://127.0.0.1:3199";
|
||||||
|
const results = [];
|
||||||
|
const check = (name, ok, detail = "") => results.push({ name, ok, detail });
|
||||||
|
|
||||||
|
// 1. Unauthenticated access is rejected.
|
||||||
|
let res = await fetch(`${base}/api/me`);
|
||||||
|
check("GET /api/me without cookie → 401", res.status === 401, `got ${res.status}`);
|
||||||
|
|
||||||
|
// 2. One-time chat link exchanges for a session cookie.
|
||||||
|
const link = await signPanelLink("user1", "tester", "srv1");
|
||||||
|
res = await fetch(`${base}/api/auth/link`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: link }),
|
||||||
|
});
|
||||||
|
const linkBody = await res.json();
|
||||||
|
const cookie = (res.headers.getSetCookie?.() ?? [])[0]?.split(";")[0] ?? "";
|
||||||
|
check("POST /api/auth/link → session", res.ok && linkBody.serverId === "srv1" && cookie.length > 0, JSON.stringify(linkBody));
|
||||||
|
|
||||||
|
// 3. Authenticated profile lists the servers we share with the bot.
|
||||||
|
res = await fetch(`${base}/api/me`, { headers: { cookie } });
|
||||||
|
const me = await res.json();
|
||||||
|
check("GET /api/me with cookie", res.ok && me.servers?.[0]?.id === "srv1", JSON.stringify(me));
|
||||||
|
|
||||||
|
// 4. Player state for a server the user belongs to.
|
||||||
|
res = await fetch(`${base}/api/servers/srv1/state`, { headers: { cookie } });
|
||||||
|
const state = await res.json();
|
||||||
|
check(
|
||||||
|
"GET state",
|
||||||
|
res.ok && state.state.status === "idle" && state.voiceChannels.length === 1,
|
||||||
|
JSON.stringify(state).slice(0, 160),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. A server the user is not a member of stays hidden.
|
||||||
|
res = await fetch(`${base}/api/servers/other/state`, { headers: { cookie } });
|
||||||
|
check("GET state for foreign server → 403", res.status === 403, `got ${res.status}`);
|
||||||
|
|
||||||
|
// 6. An expired/garbage link is refused.
|
||||||
|
res = await fetch(`${base}/api/auth/link`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: "not-a-jwt" }),
|
||||||
|
});
|
||||||
|
check("POST /api/auth/link with junk → 400", res.status === 400, `got ${res.status}`);
|
||||||
|
|
||||||
|
// 7. Actions on a server with no player report a clear error, not a crash.
|
||||||
|
res = await fetch(`${base}/api/servers/srv1/actions/pause`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", cookie },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
const pause = await res.json();
|
||||||
|
check("POST pause without player → 400", res.status === 400 && typeof pause.error === "string", JSON.stringify(pause));
|
||||||
|
|
||||||
|
// 8. Unknown action.
|
||||||
|
res = await fetch(`${base}/api/servers/srv1/actions/explode`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", cookie },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
check("POST unknown action → 404", res.status === 404, `got ${res.status}`);
|
||||||
|
|
||||||
|
// 9. Static panel bundle is served.
|
||||||
|
res = await fetch(`${base}/`);
|
||||||
|
const html = await res.text();
|
||||||
|
check("GET / serves panel", res.ok && html.includes("<div id=\"root\">"), `status ${res.status}`);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.ok) failed += 1;
|
||||||
|
console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name}${r.ok ? "" : ` — ${r.detail}`}`);
|
||||||
|
}
|
||||||
|
console.log(failed === 0 ? "\nAll checks passed" : `\n${failed} check(s) failed`);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import websocket from "@fastify/websocket";
|
||||||
|
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { signSession, verifyToken } from "../auth/tokens.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import type { MusicManager } from "../core/manager.js";
|
||||||
|
import type { BotStoatContext } from "../bot/context.js";
|
||||||
|
import { fetchSelf, loginWithPassword, revokeSession } from "../stoat/rest.js";
|
||||||
|
import { UserFacingError, type Track } from "../types.js";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "api" });
|
||||||
|
const COOKIE_NAME = "mbot_session";
|
||||||
|
const SEARCH_CACHE_TTL_MS = 15 * 60_000;
|
||||||
|
const SEARCH_CACHE_LIMIT = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel never sends whole track objects back to us — it sends ids that were
|
||||||
|
* produced by a server-side search. That keeps clients from pointing playback at
|
||||||
|
* arbitrary local paths or internal URLs.
|
||||||
|
*/
|
||||||
|
const searchCache = new Map<string, { track: Track; at: number }>();
|
||||||
|
|
||||||
|
function cacheTracks(tracks: Track[]): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const track of tracks) searchCache.set(track.id, { track, at: now });
|
||||||
|
if (searchCache.size > SEARCH_CACHE_LIMIT) {
|
||||||
|
for (const [id, entry] of searchCache) {
|
||||||
|
if (now - entry.at > SEARCH_CACHE_TTL_MS) searchCache.delete(id);
|
||||||
|
if (searchCache.size <= SEARCH_CACHE_LIMIT) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeTracks(ids: string[]): Track[] {
|
||||||
|
const tracks: Track[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const entry = searchCache.get(id);
|
||||||
|
if (!entry || Date.now() - entry.at > SEARCH_CACHE_TTL_MS) continue;
|
||||||
|
tracks.push(entry.track);
|
||||||
|
}
|
||||||
|
if (tracks.length === 0) throw new UserFacingError("Результаты поиска устарели, повторите поиск");
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
session?: Session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiServerOptions {
|
||||||
|
manager: MusicManager;
|
||||||
|
context: BotStoatContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startApiServer({ manager, context }: ApiServerOptions) {
|
||||||
|
const app = Fastify({ logger: false, trustProxy: true });
|
||||||
|
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(websocket);
|
||||||
|
|
||||||
|
const secureCookies = config.PUBLIC_URL.startsWith("https://");
|
||||||
|
|
||||||
|
function setSessionCookie(reply: FastifyReply, token: string): void {
|
||||||
|
reply.setCookie(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: secureCookies,
|
||||||
|
path: "/",
|
||||||
|
maxAge: config.SESSION_TTL_HOURS * 3600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSession(request: FastifyRequest): Promise<Session | null> {
|
||||||
|
const token = request.cookies[COOKIE_NAME];
|
||||||
|
if (!token) return null;
|
||||||
|
const claims = await verifyToken(token);
|
||||||
|
if (!claims || claims.typ !== "session") return null;
|
||||||
|
return { userId: claims.sub, username: claims.username };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireSession(request: FastifyRequest, reply: FastifyReply): Promise<Session | null> {
|
||||||
|
const session = await readSession(request);
|
||||||
|
if (!session) {
|
||||||
|
await reply.code(401).send({ error: "Не авторизован" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
request.session = session;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Membership is re-checked on every request: roles change in Stoat, not here. */
|
||||||
|
async function requireServerAccess(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<Session | null> {
|
||||||
|
const session = await requireSession(request, reply);
|
||||||
|
if (!session) return null;
|
||||||
|
if (!(await context.isMember(serverId, session.userId))) {
|
||||||
|
await reply.code(403).send({ error: "Нет доступа к этому серверу" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.setErrorHandler((error, _request, reply) => {
|
||||||
|
if (error instanceof UserFacingError) {
|
||||||
|
void reply.code(400).send({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((error as { validation?: unknown }).validation) {
|
||||||
|
void reply.code(400).send({ error: "Некорректный запрос" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.error({ err: error }, "request failed");
|
||||||
|
void reply.code(500).send({ error: "Внутренняя ошибка" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ auth ---
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().min(1).optional(),
|
||||||
|
password: z.string().min(1).optional(),
|
||||||
|
mfaTicket: z.string().optional(),
|
||||||
|
totpCode: z.string().optional(),
|
||||||
|
recoveryCode: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (request, reply) => {
|
||||||
|
const body = loginSchema.parse(request.body ?? {});
|
||||||
|
const mfa = body.mfaTicket
|
||||||
|
? { ticket: body.mfaTicket, totpCode: body.totpCode, recoveryCode: body.recoveryCode }
|
||||||
|
: undefined;
|
||||||
|
if (!mfa && (!body.email || !body.password)) {
|
||||||
|
throw new UserFacingError("Укажите e-mail и пароль");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await loginWithPassword(body.email ?? "", body.password ?? "", mfa);
|
||||||
|
if (result.kind === "mfa") {
|
||||||
|
return reply.send({ mfaRequired: true, ticket: result.ticket, methods: result.methods });
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only needed the session to prove who the user is; drop it immediately.
|
||||||
|
const profile = await fetchSelf(result.token).catch(() => null);
|
||||||
|
await revokeSession(result.token);
|
||||||
|
|
||||||
|
const username = profile?.display_name || profile?.username || "user";
|
||||||
|
const token = await signSession(result.userId, username);
|
||||||
|
setSessionCookie(reply, token);
|
||||||
|
return reply.send({ user: { id: result.userId, username } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/link", async (request, reply) => {
|
||||||
|
const body = z.object({ token: z.string().min(1) }).parse(request.body ?? {});
|
||||||
|
const claims = await verifyToken(body.token);
|
||||||
|
if (!claims || claims.typ !== "link") throw new UserFacingError("Ссылка недействительна или устарела");
|
||||||
|
const token = await signSession(claims.sub, claims.username);
|
||||||
|
setSessionCookie(reply, token);
|
||||||
|
return reply.send({ user: { id: claims.sub, username: claims.username }, serverId: claims.srv ?? null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", async (_request, reply) => {
|
||||||
|
reply.clearCookie(COOKIE_NAME, { path: "/" });
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/me", async (request, reply) => {
|
||||||
|
const session = await requireSession(request, reply);
|
||||||
|
if (!session) return reply;
|
||||||
|
const servers = await context.listServersForUser(session.userId);
|
||||||
|
return reply.send({
|
||||||
|
user: { id: session.userId, username: session.username },
|
||||||
|
servers,
|
||||||
|
features: { localLibrary: Boolean(config.LOCAL_MEDIA_DIR) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- player ---
|
||||||
|
|
||||||
|
const serverParams = z.object({ id: z.string().min(1) });
|
||||||
|
|
||||||
|
app.get("/api/servers/:id/state", async (request, reply) => {
|
||||||
|
const { id } = serverParams.parse(request.params);
|
||||||
|
const session = await requireServerAccess(request, reply, id);
|
||||||
|
if (!session) return reply;
|
||||||
|
return reply.send({
|
||||||
|
state: manager.snapshot(id),
|
||||||
|
voiceChannels: context.listVoiceChannels(id),
|
||||||
|
yourVoiceChannel: context.findUserVoiceChannel(id, session.userId),
|
||||||
|
canControl: await context.canControl(id, session.userId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/servers/:id/search", async (request, reply) => {
|
||||||
|
const { id } = serverParams.parse(request.params);
|
||||||
|
const session = await requireServerAccess(request, reply, id);
|
||||||
|
if (!session) return reply;
|
||||||
|
const { q } = z.object({ q: z.string().min(1) }).parse(request.query);
|
||||||
|
const tracks = await manager.search(q, { id: session.userId, username: session.username });
|
||||||
|
cacheTracks(tracks);
|
||||||
|
return reply.send({ tracks });
|
||||||
|
});
|
||||||
|
|
||||||
|
const playSchema = z.object({
|
||||||
|
query: z.string().min(1).optional(),
|
||||||
|
trackIds: z.array(z.string()).optional(),
|
||||||
|
mode: z.enum(["append", "next", "now"]).default("append"),
|
||||||
|
voiceChannelId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/servers/:id/play", async (request, reply) => {
|
||||||
|
const { id } = serverParams.parse(request.params);
|
||||||
|
const session = await requireServerAccess(request, reply, id);
|
||||||
|
if (!session) return reply;
|
||||||
|
const body = playSchema.parse(request.body ?? {});
|
||||||
|
const requester = { id: session.userId, username: session.username };
|
||||||
|
|
||||||
|
const outcome = body.trackIds?.length
|
||||||
|
? await manager.enqueueTracks(id, requester, takeTracks(body.trackIds), {
|
||||||
|
mode: body.mode,
|
||||||
|
voiceChannelId: body.voiceChannelId ?? null,
|
||||||
|
})
|
||||||
|
: await manager.play(id, requester, body.query ?? "", {
|
||||||
|
mode: body.mode,
|
||||||
|
voiceChannelId: body.voiceChannelId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.send({ ok: true, added: outcome.tracks.length, state: manager.snapshot(id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions: Record<string, (serverId: string, userId: string, body: unknown) => Promise<unknown>> = {
|
||||||
|
pause: (serverId, userId) => manager.pause(serverId, userId),
|
||||||
|
resume: (serverId, userId) => manager.resume(serverId, userId),
|
||||||
|
toggle: (serverId, userId) => manager.togglePause(serverId, userId),
|
||||||
|
skip: (serverId, userId, body) =>
|
||||||
|
manager.skip(serverId, userId, z.object({ count: z.number().int().min(1).default(1) }).parse(body ?? {}).count),
|
||||||
|
stop: (serverId, userId) => manager.stop(serverId, userId),
|
||||||
|
shuffle: (serverId, userId) => manager.shuffle(serverId, userId),
|
||||||
|
clear: (serverId, userId) => manager.clearQueue(serverId, userId),
|
||||||
|
leave: (serverId, userId) => manager.leave(serverId, userId),
|
||||||
|
volume: (serverId, userId, body) =>
|
||||||
|
manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume),
|
||||||
|
loop: (serverId, userId, body) =>
|
||||||
|
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
|
||||||
|
seek: (serverId, userId, body) =>
|
||||||
|
manager.seek(serverId, userId, z.object({ position: z.number().min(0) }).parse(body).position),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.post("/api/servers/:id/actions/:action", async (request, reply) => {
|
||||||
|
const { id } = serverParams.parse(request.params);
|
||||||
|
const { action } = z.object({ action: z.string() }).parse(request.params);
|
||||||
|
const session = await requireServerAccess(request, reply, id);
|
||||||
|
if (!session) return reply;
|
||||||
|
|
||||||
|
const handler = actions[action];
|
||||||
|
if (!handler) return reply.code(404).send({ error: "Неизвестное действие" });
|
||||||
|
|
||||||
|
const result = await handler(id, session.userId, request.body);
|
||||||
|
return reply.send({ ok: true, result: result ?? null, state: manager.snapshot(id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/servers/:id/join", async (request, reply) => {
|
||||||
|
const { id } = serverParams.parse(request.params);
|
||||||
|
const session = await requireServerAccess(request, reply, id);
|
||||||
|
if (!session) return reply;
|
||||||
|
const body = z.object({ voiceChannelId: z.string().nullable().optional() }).parse(request.body ?? {});
|
||||||
|
await manager.assertControl(id, session.userId);
|
||||||
|
await manager.connect(id, session.userId, { voiceChannelId: body.voiceChannelId ?? null });
|
||||||
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/servers/:id/queue/:trackId", async (request, reply) => {
|
||||||
|
const { id } = serverParams.parse(request.params);
|
||||||
|
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
|
||||||
|
const session = await requireServerAccess(request, reply, id);
|
||||||
|
if (!session) return reply;
|
||||||
|
await manager.remove(id, session.userId, trackId);
|
||||||
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/servers/:id/queue/:trackId/move", async (request, reply) => {
|
||||||
|
const { id } = serverParams.parse(request.params);
|
||||||
|
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
|
||||||
|
const session = await requireServerAccess(request, reply, id);
|
||||||
|
if (!session) return reply;
|
||||||
|
const { index } = z.object({ index: z.number().int().min(0) }).parse(request.body);
|
||||||
|
await manager.move(id, session.userId, trackId, index);
|
||||||
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- websockets ---
|
||||||
|
|
||||||
|
const subscribers = new Map<string, Set<{ send(data: string): void }>>();
|
||||||
|
|
||||||
|
function broadcast(serverId: string, payload: unknown): void {
|
||||||
|
const listeners = subscribers.get(serverId);
|
||||||
|
if (!listeners?.size) return;
|
||||||
|
const message = JSON.stringify(payload);
|
||||||
|
for (const socket of listeners) {
|
||||||
|
try {
|
||||||
|
socket.send(message);
|
||||||
|
} catch {
|
||||||
|
listeners.delete(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.on("update", (snapshot) => broadcast(snapshot.serverId, { type: "state", state: snapshot }));
|
||||||
|
manager.on("position", (position) => broadcast(position.serverId, { type: "position", ...position }));
|
||||||
|
|
||||||
|
app.get("/ws", { websocket: true }, (socket, request) => {
|
||||||
|
void (async () => {
|
||||||
|
const session = await readSession(request);
|
||||||
|
const serverId = (request.query as { server?: string }).server;
|
||||||
|
if (!session || !serverId || !(await context.isMember(serverId, session.userId))) {
|
||||||
|
socket.close(4001, "unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = subscribers.get(serverId) ?? new Set();
|
||||||
|
listeners.add(socket);
|
||||||
|
subscribers.set(serverId, listeners);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) }));
|
||||||
|
socket.on("close", () => {
|
||||||
|
listeners.delete(socket);
|
||||||
|
if (listeners.size === 0) subscribers.delete(serverId);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- static ---
|
||||||
|
|
||||||
|
const webRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)), "web/dist");
|
||||||
|
if (existsSync(webRoot)) {
|
||||||
|
await app.register(fastifyStatic, { root: webRoot });
|
||||||
|
app.setNotFoundHandler((request, reply) => {
|
||||||
|
if (request.url.startsWith("/api") || request.url.startsWith("/ws")) {
|
||||||
|
return reply.code(404).send({ error: "Not found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.warn({ webRoot }, "web/dist not found — panel UI is not served (run npm run web:build)");
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen({ port: config.PORT, host: config.HOST });
|
||||||
|
log.info({ port: config.PORT, url: config.PUBLIC_URL }, "panel is listening");
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(config.JWT_SECRET);
|
||||||
|
const ISSUER = "stoat-mbot";
|
||||||
|
|
||||||
|
export interface SessionClaims {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
/** "session" for the panel cookie, "link" for one-time links from chat. */
|
||||||
|
typ: "session" | "link";
|
||||||
|
/** Present on chat links: the server the link was issued for. */
|
||||||
|
srv?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signSession(userId: string, username: string): Promise<string> {
|
||||||
|
return new SignJWT({ username, typ: "session" })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setSubject(userId)
|
||||||
|
.setIssuer(ISSUER)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${config.SESSION_TTL_HOURS}h`)
|
||||||
|
.sign(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signPanelLink(
|
||||||
|
userId: string,
|
||||||
|
username: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return new SignJWT({ username, typ: "link", srv: serverId })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setSubject(userId)
|
||||||
|
.setIssuer(ISSUER)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime("10m")
|
||||||
|
.sign(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<SessionClaims | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, secret, { issuer: ISSUER });
|
||||||
|
if (!payload.sub || (payload["typ"] !== "session" && payload["typ"] !== "link")) return null;
|
||||||
|
return {
|
||||||
|
sub: payload.sub,
|
||||||
|
username: String(payload["username"] ?? "unknown"),
|
||||||
|
typ: payload["typ"] as "session" | "link",
|
||||||
|
srv: typeof payload["srv"] === "string" ? payload["srv"] : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { signPanelLink } from "../auth/tokens.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import type { MusicManager } from "../core/manager.js";
|
||||||
|
import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js";
|
||||||
|
import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js";
|
||||||
|
|
||||||
|
export interface CommandContext {
|
||||||
|
manager: MusicManager;
|
||||||
|
serverId: string;
|
||||||
|
channelId: string;
|
||||||
|
actor: Requester;
|
||||||
|
/** Raw text after the command name. */
|
||||||
|
rest: string;
|
||||||
|
args: string[];
|
||||||
|
reply(content: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Command {
|
||||||
|
name: string;
|
||||||
|
aliases: string[];
|
||||||
|
usage: string;
|
||||||
|
description: string;
|
||||||
|
run(ctx: CommandContext): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_TTL_MS = 5 * 60_000;
|
||||||
|
const searchSessions = new Map<string, { tracks: Track[]; expiresAt: number }>();
|
||||||
|
|
||||||
|
function rememberSearch(ctx: CommandContext, tracks: Track[]): void {
|
||||||
|
searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, {
|
||||||
|
tracks,
|
||||||
|
expiresAt: Date.now() + SEARCH_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recallSearch(ctx: CommandContext): Track[] | null {
|
||||||
|
const key = `${ctx.channelId}:${ctx.actor.id}`;
|
||||||
|
const entry = searchSessions.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (entry.expiresAt < Date.now()) {
|
||||||
|
searchSessions.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise<void> {
|
||||||
|
if (!ctx.rest) throw new UserFacingError("Укажите название трека или ссылку");
|
||||||
|
const outcome = await ctx.manager.play(ctx.serverId, ctx.actor, ctx.rest, {
|
||||||
|
mode,
|
||||||
|
textChannelId: ctx.channelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outcome.playlist) {
|
||||||
|
await ctx.reply(
|
||||||
|
`📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const track = outcome.tracks[0];
|
||||||
|
if (!track) return;
|
||||||
|
if (outcome.startedNow || mode === "now") {
|
||||||
|
await ctx.reply(`▶️ Играю: ${trackLine(track)}`);
|
||||||
|
} else {
|
||||||
|
await ctx.reply(`➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commands: Command[] = [
|
||||||
|
{
|
||||||
|
name: "play",
|
||||||
|
aliases: ["p", "играй"],
|
||||||
|
usage: "play <ссылка или название>",
|
||||||
|
description: "Добавить трек или плейлист в очередь",
|
||||||
|
run: (ctx) => playCommand(ctx, "append"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "playnext",
|
||||||
|
aliases: ["pn", "next"],
|
||||||
|
usage: "playnext <ссылка или название>",
|
||||||
|
description: "Поставить трек следующим в очереди",
|
||||||
|
run: (ctx) => playCommand(ctx, "next"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "playnow",
|
||||||
|
aliases: ["now"],
|
||||||
|
usage: "playnow <ссылка или название>",
|
||||||
|
description: "Включить трек немедленно",
|
||||||
|
run: (ctx) => playCommand(ctx, "now"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search",
|
||||||
|
aliases: ["s", "найди"],
|
||||||
|
usage: "search <запрос>",
|
||||||
|
description: "Найти треки и выбрать нужный командой pick",
|
||||||
|
async run(ctx) {
|
||||||
|
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
|
||||||
|
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
await ctx.reply("Ничего не найдено.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rememberSearch(ctx, tracks);
|
||||||
|
const lines = tracks.map((track, index) => trackLine(track, index + 1));
|
||||||
|
await ctx.reply(
|
||||||
|
`🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pick",
|
||||||
|
aliases: ["выбрать"],
|
||||||
|
usage: "pick <номер>",
|
||||||
|
description: "Добавить трек из результатов поиска",
|
||||||
|
async run(ctx) {
|
||||||
|
const tracks = recallSearch(ctx);
|
||||||
|
if (!tracks) throw new UserFacingError("Сначала выполните поиск");
|
||||||
|
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
|
const track = tracks[index - 1];
|
||||||
|
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`);
|
||||||
|
const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], {
|
||||||
|
textChannelId: ctx.channelId,
|
||||||
|
});
|
||||||
|
await ctx.reply(
|
||||||
|
outcome.startedNow
|
||||||
|
? `▶️ Играю: ${trackLine(track)}`
|
||||||
|
: `➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skip",
|
||||||
|
aliases: ["sk", "пропусти"],
|
||||||
|
usage: "skip [количество]",
|
||||||
|
description: "Пропустить текущий трек",
|
||||||
|
async run(ctx) {
|
||||||
|
const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1);
|
||||||
|
const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count);
|
||||||
|
await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stop",
|
||||||
|
aliases: ["стоп"],
|
||||||
|
usage: "stop",
|
||||||
|
description: "Остановить воспроизведение и очистить очередь",
|
||||||
|
async run(ctx) {
|
||||||
|
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
|
||||||
|
await ctx.reply("⏹️ Остановлено, очередь очищена.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pause",
|
||||||
|
aliases: ["пауза"],
|
||||||
|
usage: "pause",
|
||||||
|
description: "Поставить на паузу / снять с паузы",
|
||||||
|
async run(ctx) {
|
||||||
|
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
|
||||||
|
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resume",
|
||||||
|
aliases: ["продолжи"],
|
||||||
|
usage: "resume",
|
||||||
|
description: "Продолжить воспроизведение",
|
||||||
|
async run(ctx) {
|
||||||
|
await ctx.manager.resume(ctx.serverId, ctx.actor.id);
|
||||||
|
await ctx.reply("▶️ Продолжаю.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "queue",
|
||||||
|
aliases: ["q", "очередь"],
|
||||||
|
usage: "queue [страница]",
|
||||||
|
description: "Показать очередь",
|
||||||
|
async run(ctx) {
|
||||||
|
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||||
|
if (!snapshot.current && snapshot.queue.length === 0) {
|
||||||
|
await ctx.reply("Очередь пуста.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pageSize = 10;
|
||||||
|
const pages = Math.max(1, Math.ceil(snapshot.queue.length / pageSize));
|
||||||
|
const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1));
|
||||||
|
const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (snapshot.current) {
|
||||||
|
parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`);
|
||||||
|
parts.push(progressBar(snapshot.position, snapshot.current.duration));
|
||||||
|
}
|
||||||
|
if (slice.length > 0) {
|
||||||
|
const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1));
|
||||||
|
parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`);
|
||||||
|
}
|
||||||
|
const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
|
||||||
|
parts.push(
|
||||||
|
`Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`,
|
||||||
|
);
|
||||||
|
await ctx.reply(parts.join("\n\n"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nowplaying",
|
||||||
|
aliases: ["np", "сейчас"],
|
||||||
|
usage: "nowplaying",
|
||||||
|
description: "Показать текущий трек",
|
||||||
|
async run(ctx) {
|
||||||
|
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||||
|
if (!snapshot.current) {
|
||||||
|
await ctx.reply("Сейчас ничего не играет.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ctx.reply(
|
||||||
|
`🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volume",
|
||||||
|
aliases: ["vol", "громкость"],
|
||||||
|
usage: "volume [0-200]",
|
||||||
|
description: "Показать или изменить громкость",
|
||||||
|
async run(ctx) {
|
||||||
|
if (ctx.args.length === 0) {
|
||||||
|
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
|
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
|
||||||
|
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
|
||||||
|
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loop",
|
||||||
|
aliases: ["repeat", "повтор"],
|
||||||
|
usage: "loop [off|track|queue]",
|
||||||
|
description: "Режим повтора",
|
||||||
|
async run(ctx) {
|
||||||
|
const raw = (ctx.args[0] ?? "").toLowerCase();
|
||||||
|
const map: Record<string, LoopMode> = {
|
||||||
|
off: "off",
|
||||||
|
выкл: "off",
|
||||||
|
track: "track",
|
||||||
|
трек: "track",
|
||||||
|
one: "track",
|
||||||
|
queue: "queue",
|
||||||
|
очередь: "queue",
|
||||||
|
all: "queue",
|
||||||
|
};
|
||||||
|
const current = ctx.manager.snapshot(ctx.serverId).loop;
|
||||||
|
const nextMode =
|
||||||
|
map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off");
|
||||||
|
await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode);
|
||||||
|
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shuffle",
|
||||||
|
aliases: ["sh", "перемешай"],
|
||||||
|
usage: "shuffle",
|
||||||
|
description: "Перемешать очередь",
|
||||||
|
async run(ctx) {
|
||||||
|
await ctx.manager.shuffle(ctx.serverId, ctx.actor.id);
|
||||||
|
await ctx.reply("🔀 Очередь перемешана.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove",
|
||||||
|
aliases: ["rm", "удали"],
|
||||||
|
usage: "remove <номер>",
|
||||||
|
description: "Убрать трек из очереди",
|
||||||
|
async run(ctx) {
|
||||||
|
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
|
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||||
|
const track = snapshot.queue[index - 1];
|
||||||
|
if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди");
|
||||||
|
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
|
||||||
|
await ctx.reply(`🗑️ Удалено: **${track.title}**`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear",
|
||||||
|
aliases: ["очисти"],
|
||||||
|
usage: "clear",
|
||||||
|
description: "Очистить очередь, не останавливая текущий трек",
|
||||||
|
async run(ctx) {
|
||||||
|
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
|
||||||
|
await ctx.reply("🧹 Очередь очищена.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "seek",
|
||||||
|
aliases: ["перемотай"],
|
||||||
|
usage: "seek <мм:сс>",
|
||||||
|
description: "Перемотать текущий трек",
|
||||||
|
async run(ctx) {
|
||||||
|
const seconds = parseTimecode(ctx.rest);
|
||||||
|
if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`");
|
||||||
|
await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds);
|
||||||
|
await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "join",
|
||||||
|
aliases: ["зайди"],
|
||||||
|
usage: "join",
|
||||||
|
description: "Позвать бота в ваш голосовой канал",
|
||||||
|
async run(ctx) {
|
||||||
|
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
|
||||||
|
const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, {
|
||||||
|
textChannelId: ctx.channelId,
|
||||||
|
});
|
||||||
|
await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leave",
|
||||||
|
aliases: ["dc", "выйди"],
|
||||||
|
usage: "leave",
|
||||||
|
description: "Выйти из голосового канала",
|
||||||
|
async run(ctx) {
|
||||||
|
await ctx.manager.leave(ctx.serverId, ctx.actor.id);
|
||||||
|
await ctx.reply("👋 Вышел из голосового канала.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "panel",
|
||||||
|
aliases: ["ui", "панель"],
|
||||||
|
usage: "panel",
|
||||||
|
description: "Получить ссылку на веб-панель",
|
||||||
|
async run(ctx) {
|
||||||
|
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
|
||||||
|
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
|
||||||
|
await ctx.reply(
|
||||||
|
`🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "help",
|
||||||
|
aliases: ["h", "помощь"],
|
||||||
|
usage: "help",
|
||||||
|
description: "Показать список команд",
|
||||||
|
async run(ctx) {
|
||||||
|
const lines = commands.map(
|
||||||
|
(command) => `\`${config.COMMAND_PREFIX}${command.usage}\` — ${command.description}`,
|
||||||
|
);
|
||||||
|
await ctx.reply(
|
||||||
|
`**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const lookup = new Map<string, Command>();
|
||||||
|
for (const command of commands) {
|
||||||
|
lookup.set(command.name, command);
|
||||||
|
for (const alias of command.aliases) lookup.set(alias, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCommand(name: string): Command | undefined {
|
||||||
|
return lookup.get(name.toLowerCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Client } from "stoat.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import type { ServerRef, StoatContext, VoiceChannelRef } from "../core/manager.js";
|
||||||
|
import { fetchMember } from "../stoat/rest.js";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "bot-context" });
|
||||||
|
const MEMBERSHIP_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
interface MembershipInfo {
|
||||||
|
isMember: boolean;
|
||||||
|
roles: string[];
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Implements the core's view of Stoat on top of the live bot client. */
|
||||||
|
export class BotStoatContext implements StoatContext {
|
||||||
|
private readonly membershipCache = new Map<string, MembershipInfo>();
|
||||||
|
|
||||||
|
constructor(private readonly client: Client) {}
|
||||||
|
|
||||||
|
getServerName(serverId: string): string | null {
|
||||||
|
return this.client.servers.get(serverId)?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVoiceChannel(channelId: string): VoiceChannelRef | null {
|
||||||
|
const channel = this.client.channels.get(channelId);
|
||||||
|
if (!channel?.isVoice) return null;
|
||||||
|
return { id: channel.id, name: channel.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
listVoiceChannels(serverId: string): VoiceChannelRef[] {
|
||||||
|
const server = this.client.servers.get(serverId);
|
||||||
|
if (!server) return [];
|
||||||
|
return server.channels
|
||||||
|
.filter((channel) => channel.isVoice)
|
||||||
|
.map((channel) => ({ id: channel.id, name: channel.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null {
|
||||||
|
const server = this.client.servers.get(serverId);
|
||||||
|
if (!server) return null;
|
||||||
|
for (const channel of server.channels) {
|
||||||
|
if (!channel.isVoice) continue;
|
||||||
|
if (channel.voiceParticipants.has(userId)) {
|
||||||
|
return { id: channel.id, name: channel.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Servers where both the bot and the given user are members. */
|
||||||
|
async listServersForUser(userId: string): Promise<ServerRef[]> {
|
||||||
|
const servers = [...this.client.servers.values()];
|
||||||
|
const checks = await Promise.all(
|
||||||
|
servers.map(async (server) => {
|
||||||
|
const info = await this.membership(server.id, userId);
|
||||||
|
if (!info.isMember) return null;
|
||||||
|
return {
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
iconUrl: server.icon?.createFileURL() ?? null,
|
||||||
|
} satisfies ServerRef;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return checks.filter((entry): entry is ServerRef => entry !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isMember(serverId: string, userId: string): Promise<boolean> {
|
||||||
|
return (await this.membership(serverId, userId)).isMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
async canControl(serverId: string, userId: string): Promise<boolean> {
|
||||||
|
const info = await this.membership(serverId, userId);
|
||||||
|
if (!info.isMember) return false;
|
||||||
|
if (!config.REQUIRE_DJ_ROLE) return true;
|
||||||
|
|
||||||
|
const server = this.client.servers.get(serverId);
|
||||||
|
if (!server) return false;
|
||||||
|
if (server.owner?.id === userId) return true;
|
||||||
|
|
||||||
|
const member = server.getMember(userId);
|
||||||
|
if (member?.hasPermission(server, "ManageServer")) return true;
|
||||||
|
|
||||||
|
const djRole = [...server.roles.entries()].find(
|
||||||
|
([, role]) => role.name.toLowerCase() === config.DJ_ROLE_NAME.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!djRole) return false;
|
||||||
|
return info.roles.includes(djRole[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async membership(serverId: string, userId: string): Promise<MembershipInfo> {
|
||||||
|
const key = `${serverId}:${userId}`;
|
||||||
|
const cached = this.membershipCache.get(key);
|
||||||
|
if (cached && Date.now() - cached.at < MEMBERSHIP_TTL_MS) return cached;
|
||||||
|
|
||||||
|
const cachedMember = this.client.servers.get(serverId)?.getMember(userId);
|
||||||
|
if (cachedMember) {
|
||||||
|
const info: MembershipInfo = { isMember: true, roles: cachedMember.roles ?? [], at: Date.now() };
|
||||||
|
this.membershipCache.set(key, info);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
let info: MembershipInfo = { isMember: false, roles: [], at: Date.now() };
|
||||||
|
try {
|
||||||
|
const member = await fetchMember(serverId, userId);
|
||||||
|
if (member) info = { isMember: true, roles: member.roles ?? [], at: Date.now() };
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, serverId, userId }, "membership lookup failed");
|
||||||
|
}
|
||||||
|
this.membershipCache.set(key, info);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(channelId: string, content: string): Promise<void> {
|
||||||
|
const channel = this.client.channels.get(channelId);
|
||||||
|
if (!channel) return;
|
||||||
|
await channel.sendMessage(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { LoopMode, Track } from "../types.js";
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
|
||||||
|
const total = Math.floor(seconds);
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const secs = total % 60;
|
||||||
|
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||||
|
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimecode(input: string): number | null {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
||||||
|
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, hours, minutes, seconds] = match;
|
||||||
|
return (
|
||||||
|
Number.parseInt(hours ?? "0", 10) * 3600 +
|
||||||
|
Number.parseInt(minutes ?? "0", 10) * 60 +
|
||||||
|
Number.parseInt(seconds ?? "0", 10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function progressBar(position: number, duration: number, width = 22): string {
|
||||||
|
if (duration <= 0) return "🔴 прямой эфир";
|
||||||
|
const ratio = Math.min(1, Math.max(0, position / duration));
|
||||||
|
const filled = Math.round(ratio * (width - 1));
|
||||||
|
const bar = `${"─".repeat(filled)}⬤${"─".repeat(Math.max(0, width - 1 - filled))}`;
|
||||||
|
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_LABEL: Record<Track["source"], string> = {
|
||||||
|
youtube: "YouTube",
|
||||||
|
soundcloud: "SoundCloud",
|
||||||
|
direct: "Ссылка",
|
||||||
|
local: "Медиатека",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function trackLine(track: Track, index?: number): string {
|
||||||
|
const prefix = index === undefined ? "" : `**${index}.** `;
|
||||||
|
const author = track.author ? ` — ${track.author}` : "";
|
||||||
|
const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title;
|
||||||
|
return `${prefix}${link}${author} \`[${formatDuration(track.duration)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loopLabel(mode: LoopMode): string {
|
||||||
|
if (mode === "track") return "трек";
|
||||||
|
if (mode === "queue") return "очередь";
|
||||||
|
return "выключен";
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Client, type Message } from "stoat.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import type { MusicManager } from "../core/manager.js";
|
||||||
|
import { UserFacingError } from "../types.js";
|
||||||
|
import { findCommand, type CommandContext } from "./commands.js";
|
||||||
|
import { BotStoatContext } from "./context.js";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "bot" });
|
||||||
|
|
||||||
|
export interface Bot {
|
||||||
|
client: Client;
|
||||||
|
context: BotStoatContext;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBot(manager: MusicManager): Promise<Bot> {
|
||||||
|
const client = new Client({ baseURL: config.STOAT_API_URL });
|
||||||
|
const context = new BotStoatContext(client);
|
||||||
|
manager.attachStoat(context);
|
||||||
|
|
||||||
|
client.on("ready", () => {
|
||||||
|
log.info({ user: client.user?.username }, "bot is ready");
|
||||||
|
});
|
||||||
|
client.on("error", (error) => {
|
||||||
|
log.error({ err: error }, "client error");
|
||||||
|
});
|
||||||
|
client.on("disconnected", () => log.warn("gateway disconnected"));
|
||||||
|
|
||||||
|
client.on("messageCreate", (message) => {
|
||||||
|
void handleMessage(manager, message).catch((err) => {
|
||||||
|
log.error({ err }, "unhandled command failure");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.loginBot(config.STOAT_BOT_TOKEN);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
async stop() {
|
||||||
|
await manager.destroyAll();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessage(manager: MusicManager, message: Message): Promise<void> {
|
||||||
|
const content = message.content?.trim();
|
||||||
|
if (!content || !content.startsWith(config.COMMAND_PREFIX)) return;
|
||||||
|
if (!message.authorId || message.author?.bot) return;
|
||||||
|
|
||||||
|
const serverId = message.server?.id;
|
||||||
|
const reply = async (text: string) => {
|
||||||
|
await message.channel?.sendMessage(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
await reply("Команды работают только внутри сервера.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutPrefix = content.slice(config.COMMAND_PREFIX.length).trim();
|
||||||
|
const [rawName, ...args] = withoutPrefix.split(/\s+/);
|
||||||
|
if (!rawName) return;
|
||||||
|
|
||||||
|
const command = findCommand(rawName);
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
const ctx: CommandContext = {
|
||||||
|
manager,
|
||||||
|
serverId,
|
||||||
|
channelId: message.channelId,
|
||||||
|
actor: {
|
||||||
|
id: message.authorId,
|
||||||
|
username: message.member?.nickname || message.author?.username || "user",
|
||||||
|
},
|
||||||
|
args,
|
||||||
|
rest: withoutPrefix.slice(rawName.length).trim(),
|
||||||
|
reply,
|
||||||
|
};
|
||||||
|
|
||||||
|
log.debug({ command: command.name, user: ctx.actor.id, server: serverId }, "command");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.run(ctx);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UserFacingError) {
|
||||||
|
await reply(`⚠️ ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.error({ err, command: command.name }, "command failed");
|
||||||
|
await reply("⚠️ Внутренняя ошибка, подробности в логах бота.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Minimal .env loader so we don't need an extra dependency.
|
||||||
|
function loadDotEnv(path = ".env"): void {
|
||||||
|
if (!existsSync(path)) return;
|
||||||
|
for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
const eq = line.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = line.slice(0, eq).trim();
|
||||||
|
let value = line.slice(eq + 1).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (process.env[key] === undefined) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDotEnv();
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
STOAT_API_URL: z.string().url(),
|
||||||
|
STOAT_BOT_TOKEN: z.string().min(1),
|
||||||
|
COMMAND_PREFIX: z.string().min(1).default("!"),
|
||||||
|
|
||||||
|
PORT: z.coerce.number().int().positive().default(3005),
|
||||||
|
HOST: z.string().default("0.0.0.0"),
|
||||||
|
PUBLIC_URL: z.string().url(),
|
||||||
|
JWT_SECRET: z.string().min(16),
|
||||||
|
SESSION_TTL_HOURS: z.coerce.number().positive().default(168),
|
||||||
|
|
||||||
|
YTDLP_PATH: z.string().default("yt-dlp"),
|
||||||
|
YTDLP_COOKIES: z.string().optional(),
|
||||||
|
LOCAL_MEDIA_DIR: z.string().optional(),
|
||||||
|
|
||||||
|
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
||||||
|
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
|
||||||
|
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
|
||||||
|
IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(300),
|
||||||
|
DJ_ROLE_NAME: z.string().default("DJ"),
|
||||||
|
REQUIRE_DJ_ROLE: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.default("false")
|
||||||
|
.transform((value) => value === "true"),
|
||||||
|
|
||||||
|
LOG_LEVEL: z.string().default("info"),
|
||||||
|
NODE_ENV: z.string().default("development"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(process.env);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
const issues = parsed.error.issues
|
||||||
|
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||||
|
.join("\n");
|
||||||
|
console.error(`Invalid configuration, check your .env file:\n${issues}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
...parsed.data,
|
||||||
|
STOAT_API_URL: parsed.data.STOAT_API_URL.replace(/\/+$/, ""),
|
||||||
|
PUBLIC_URL: parsed.data.PUBLIC_URL.replace(/\/+$/, ""),
|
||||||
|
isProduction: parsed.data.NODE_ENV === "production",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Config = typeof config;
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { resolveQuery, searchTracks } from "../sources/index.js";
|
||||||
|
import {
|
||||||
|
UserFacingError,
|
||||||
|
type LoopMode,
|
||||||
|
type PlayerSnapshot,
|
||||||
|
type Requester,
|
||||||
|
type SearchResult,
|
||||||
|
type Track,
|
||||||
|
} from "../types.js";
|
||||||
|
import { GuildPlayer, type Notice, type PositionUpdate } from "./player.js";
|
||||||
|
import { Revoice, type RevoiceLike } from "./revoice.js";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "manager" });
|
||||||
|
|
||||||
|
export interface VoiceChannelRef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the core needs to know about the chat side of Stoat. Implemented on
|
||||||
|
* top of the bot's stoat.js client so the player itself stays testable.
|
||||||
|
*/
|
||||||
|
export interface StoatContext {
|
||||||
|
findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null;
|
||||||
|
getVoiceChannel(channelId: string): VoiceChannelRef | null;
|
||||||
|
listVoiceChannels(serverId: string): VoiceChannelRef[];
|
||||||
|
getServerName(serverId: string): string | null;
|
||||||
|
listServersForUser(userId: string): Promise<ServerRef[]>;
|
||||||
|
isMember(serverId: string, userId: string): Promise<boolean>;
|
||||||
|
canControl(serverId: string, userId: string): Promise<boolean>;
|
||||||
|
sendMessage(channelId: string, content: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayMode = "append" | "next" | "now";
|
||||||
|
|
||||||
|
export interface ManagerEvents {
|
||||||
|
update: [PlayerSnapshot];
|
||||||
|
position: [PositionUpdate];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayOutcome extends SearchResult {
|
||||||
|
startedNow: boolean;
|
||||||
|
queuePosition: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns one GuildPlayer per server and exposes the high-level operations that
|
||||||
|
* both the chat commands and the web panel call into.
|
||||||
|
*/
|
||||||
|
export class MusicManager extends EventEmitter<ManagerEvents> {
|
||||||
|
private readonly players = new Map<string, GuildPlayer>();
|
||||||
|
private readonly revoice: RevoiceLike;
|
||||||
|
private stoat: StoatContext | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.revoice = new Revoice(config.STOAT_BOT_TOKEN, { baseURL: config.STOAT_API_URL });
|
||||||
|
}
|
||||||
|
|
||||||
|
attachStoat(context: StoatContext): void {
|
||||||
|
this.stoat = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get chat(): StoatContext {
|
||||||
|
if (!this.stoat) throw new UserFacingError("Бот ещё не подключился к Stoat");
|
||||||
|
return this.stoat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- players ---
|
||||||
|
|
||||||
|
get(serverId: string): GuildPlayer | undefined {
|
||||||
|
return this.players.get(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): GuildPlayer[] {
|
||||||
|
return [...this.players.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreate(serverId: string): GuildPlayer {
|
||||||
|
const existing = this.players.get(serverId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const player = new GuildPlayer({
|
||||||
|
serverId,
|
||||||
|
serverName: this.stoat?.getServerName(serverId) ?? null,
|
||||||
|
revoice: this.revoice,
|
||||||
|
});
|
||||||
|
player.on("update", (snapshot) => this.emit("update", snapshot));
|
||||||
|
player.on("position", (position) => this.emit("position", position));
|
||||||
|
player.on("notice", (notice) => void this.deliverNotice(notice));
|
||||||
|
this.players.set(serverId, player);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverNotice(notice: Notice): Promise<void> {
|
||||||
|
if (!notice.textChannelId || !this.stoat) return;
|
||||||
|
try {
|
||||||
|
await this.stoat.sendMessage(notice.textChannelId, notice.text);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, channel: notice.textChannelId }, "failed to deliver notice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(serverId: string): Promise<void> {
|
||||||
|
const player = this.players.get(serverId);
|
||||||
|
if (!player) return;
|
||||||
|
this.players.delete(serverId);
|
||||||
|
await player.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroyAll(): Promise<void> {
|
||||||
|
await Promise.allSettled([...this.players.keys()].map((id) => this.destroy(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ permissions ---
|
||||||
|
|
||||||
|
async assertControl(serverId: string, userId: string): Promise<void> {
|
||||||
|
if (!(await this.chat.canControl(serverId, userId))) {
|
||||||
|
throw new UserFacingError("Недостаточно прав для управления плеером");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- actions ---
|
||||||
|
|
||||||
|
/** Connects to the caller's voice channel (or an explicit one) and returns the player. */
|
||||||
|
async connect(
|
||||||
|
serverId: string,
|
||||||
|
userId: string,
|
||||||
|
options: { voiceChannelId?: string | null; textChannelId?: string | null } = {},
|
||||||
|
): Promise<GuildPlayer> {
|
||||||
|
const player = this.getOrCreate(serverId);
|
||||||
|
if (options.textChannelId) player.textChannelId = options.textChannelId;
|
||||||
|
|
||||||
|
const target = options.voiceChannelId
|
||||||
|
? this.chat.getVoiceChannel(options.voiceChannelId)
|
||||||
|
: (this.chat.findUserVoiceChannel(serverId, userId) ??
|
||||||
|
(player.voiceChannelId ? this.chat.getVoiceChannel(player.voiceChannelId) : null));
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new UserFacingError("Зайдите в голосовой канал или укажите его явно");
|
||||||
|
}
|
||||||
|
await player.connect(target.id, target.name);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(
|
||||||
|
serverId: string,
|
||||||
|
requester: Requester,
|
||||||
|
query: string,
|
||||||
|
options: { mode?: PlayMode; voiceChannelId?: string | null; textChannelId?: string | null } = {},
|
||||||
|
): Promise<PlayOutcome> {
|
||||||
|
await this.assertControl(serverId, requester.id);
|
||||||
|
const player = await this.connect(serverId, requester.id, {
|
||||||
|
voiceChannelId: options.voiceChannelId ?? null,
|
||||||
|
textChannelId: options.textChannelId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveQuery(query, requester, config.MAX_QUEUE_SIZE - player.queue.length);
|
||||||
|
if (result.tracks.length === 0) throw new UserFacingError("Ничего не найдено");
|
||||||
|
|
||||||
|
const mode = options.mode ?? "append";
|
||||||
|
const wasIdle = !player.current;
|
||||||
|
|
||||||
|
if (mode === "now") {
|
||||||
|
await player.playNow(result.tracks);
|
||||||
|
return { ...result, startedNow: true, queuePosition: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
player.enqueue(result.tracks, mode === "next" ? 0 : undefined);
|
||||||
|
const queuePosition = mode === "next" ? 1 : player.queue.length - result.tracks.length + 1;
|
||||||
|
await player.ensurePlaying();
|
||||||
|
return { ...result, startedNow: wasIdle, queuePosition };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queues already-resolved tracks (used by the panel's search results). */
|
||||||
|
async enqueueTracks(
|
||||||
|
serverId: string,
|
||||||
|
requester: Requester,
|
||||||
|
tracks: Track[],
|
||||||
|
options: { mode?: PlayMode; voiceChannelId?: string | null; textChannelId?: string | null } = {},
|
||||||
|
): Promise<PlayOutcome> {
|
||||||
|
await this.assertControl(serverId, requester.id);
|
||||||
|
const player = await this.connect(serverId, requester.id, {
|
||||||
|
voiceChannelId: options.voiceChannelId ?? null,
|
||||||
|
textChannelId: options.textChannelId ?? null,
|
||||||
|
});
|
||||||
|
const owned = tracks.map((track) => ({ ...track, requestedBy: requester }));
|
||||||
|
const wasIdle = !player.current;
|
||||||
|
|
||||||
|
if (options.mode === "now") {
|
||||||
|
await player.playNow(owned);
|
||||||
|
return { tracks: owned, playlist: null, startedNow: true, queuePosition: 0 };
|
||||||
|
}
|
||||||
|
player.enqueue(owned, options.mode === "next" ? 0 : undefined);
|
||||||
|
await player.ensurePlaying();
|
||||||
|
return {
|
||||||
|
tracks: owned,
|
||||||
|
playlist: null,
|
||||||
|
startedNow: wasIdle,
|
||||||
|
queuePosition: options.mode === "next" ? 1 : player.queue.length - owned.length + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
search(query: string, requester: Requester, limit?: number): Promise<Track[]> {
|
||||||
|
return searchTracks(query, requester, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async require(serverId: string, userId: string): Promise<GuildPlayer> {
|
||||||
|
await this.assertControl(serverId, userId);
|
||||||
|
const player = this.players.get(serverId);
|
||||||
|
if (!player) throw new UserFacingError("Плеер не запущен на этом сервере");
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(serverId: string, userId: string): Promise<void> {
|
||||||
|
(await this.require(serverId, userId)).pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(serverId: string, userId: string): Promise<void> {
|
||||||
|
(await this.require(serverId, userId)).resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
async togglePause(serverId: string, userId: string): Promise<"paused" | "playing"> {
|
||||||
|
const player = await this.require(serverId, userId);
|
||||||
|
if (player.snapshot().status === "paused") {
|
||||||
|
player.resume();
|
||||||
|
return "playing";
|
||||||
|
}
|
||||||
|
player.pause();
|
||||||
|
return "paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
async skip(serverId: string, userId: string, count = 1): Promise<Track | null> {
|
||||||
|
return (await this.require(serverId, userId)).skip(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(serverId: string, userId: string): Promise<void> {
|
||||||
|
await (await this.require(serverId, userId)).stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVolume(serverId: string, userId: string, volume: number): Promise<void> {
|
||||||
|
(await this.require(serverId, userId)).setVolume(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLoop(serverId: string, userId: string, mode: LoopMode): Promise<void> {
|
||||||
|
(await this.require(serverId, userId)).setLoop(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shuffle(serverId: string, userId: string): Promise<void> {
|
||||||
|
(await this.require(serverId, userId)).shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(serverId: string, userId: string, seconds: number): Promise<void> {
|
||||||
|
await (await this.require(serverId, userId)).seek(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(serverId: string, userId: string, trackId: string): Promise<Track> {
|
||||||
|
return (await this.require(serverId, userId)).remove(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(serverId: string, userId: string, trackId: string, toIndex: number): Promise<void> {
|
||||||
|
(await this.require(serverId, userId)).move(trackId, toIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearQueue(serverId: string, userId: string): Promise<void> {
|
||||||
|
(await this.require(serverId, userId)).clearQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async leave(serverId: string, userId: string): Promise<void> {
|
||||||
|
await (await this.require(serverId, userId)).leaveVoice();
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(serverId: string): PlayerSnapshot {
|
||||||
|
const player = this.players.get(serverId);
|
||||||
|
if (player) return player.snapshot();
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
serverName: this.stoat?.getServerName(serverId) ?? null,
|
||||||
|
voiceChannelId: null,
|
||||||
|
voiceChannelName: null,
|
||||||
|
textChannelId: null,
|
||||||
|
status: "idle",
|
||||||
|
current: null,
|
||||||
|
position: 0,
|
||||||
|
queue: [],
|
||||||
|
history: [],
|
||||||
|
volume: config.DEFAULT_VOLUME,
|
||||||
|
loop: "off",
|
||||||
|
shuffleUsed: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { openPlayback, type PlaybackInput } from "../sources/index.js";
|
||||||
|
import {
|
||||||
|
UserFacingError,
|
||||||
|
type LoopMode,
|
||||||
|
type PlayerSnapshot,
|
||||||
|
type PlayerStatus,
|
||||||
|
type Track,
|
||||||
|
} from "../types.js";
|
||||||
|
import {
|
||||||
|
MediaPlayer,
|
||||||
|
parseFfmpegDuration,
|
||||||
|
type MediaPlayerLike,
|
||||||
|
type RevoiceLike,
|
||||||
|
type VoiceConnectionLike,
|
||||||
|
} from "./revoice.js";
|
||||||
|
|
||||||
|
const HISTORY_LIMIT = 50;
|
||||||
|
const JOIN_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
export interface PositionUpdate {
|
||||||
|
serverId: string;
|
||||||
|
position: number;
|
||||||
|
duration: number;
|
||||||
|
status: PlayerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notice {
|
||||||
|
serverId: string;
|
||||||
|
textChannelId: string | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuildPlayerEvents {
|
||||||
|
update: [PlayerSnapshot];
|
||||||
|
position: [PositionUpdate];
|
||||||
|
notice: [Notice];
|
||||||
|
destroyed: [{ serverId: string }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuildPlayerOptions {
|
||||||
|
serverId: string;
|
||||||
|
serverName: string | null;
|
||||||
|
revoice: RevoiceLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns everything about music playback for one Stoat server: the voice
|
||||||
|
* connection, the queue and the ffmpeg-backed media player. Chat commands and
|
||||||
|
* the web panel both drive playback exclusively through this class, so the two
|
||||||
|
* can never drift apart.
|
||||||
|
*/
|
||||||
|
export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
||||||
|
readonly serverId: string;
|
||||||
|
serverName: string | null;
|
||||||
|
textChannelId: string | null = null;
|
||||||
|
voiceChannelId: string | null = null;
|
||||||
|
voiceChannelName: string | null = null;
|
||||||
|
|
||||||
|
queue: Track[] = [];
|
||||||
|
history: Track[] = [];
|
||||||
|
current: Track | null = null;
|
||||||
|
volume = config.DEFAULT_VOLUME;
|
||||||
|
loop: LoopMode = "off";
|
||||||
|
shuffleUsed = false;
|
||||||
|
|
||||||
|
private status: PlayerStatus = "idle";
|
||||||
|
private readonly revoice: RevoiceLike;
|
||||||
|
private connection: VoiceConnectionLike | null = null;
|
||||||
|
private media: MediaPlayerLike | null = null;
|
||||||
|
private currentInput: PlaybackInput | null = null;
|
||||||
|
private seekOffset = 0;
|
||||||
|
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
|
||||||
|
private expectingStop = false;
|
||||||
|
private idleTimer: NodeJS.Timeout | null = null;
|
||||||
|
private ticker: NodeJS.Timeout | null = null;
|
||||||
|
private readonly log;
|
||||||
|
|
||||||
|
constructor(options: GuildPlayerOptions) {
|
||||||
|
super();
|
||||||
|
this.serverId = options.serverId;
|
||||||
|
this.serverName = options.serverName;
|
||||||
|
this.revoice = options.revoice;
|
||||||
|
this.log = logger.child({ mod: "player", server: options.serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- state ---
|
||||||
|
|
||||||
|
get position(): number {
|
||||||
|
if (!this.media) return 0;
|
||||||
|
return this.seekOffset + this.media.seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): PlayerSnapshot {
|
||||||
|
return {
|
||||||
|
serverId: this.serverId,
|
||||||
|
serverName: this.serverName,
|
||||||
|
voiceChannelId: this.voiceChannelId,
|
||||||
|
voiceChannelName: this.voiceChannelName,
|
||||||
|
textChannelId: this.textChannelId,
|
||||||
|
status: this.status,
|
||||||
|
current: this.current,
|
||||||
|
position: Math.round(this.position * 10) / 10,
|
||||||
|
queue: this.queue,
|
||||||
|
history: this.history.slice(0, 10),
|
||||||
|
volume: this.volume,
|
||||||
|
loop: this.loop,
|
||||||
|
shuffleUsed: this.shuffleUsed,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(status: PlayerStatus): void {
|
||||||
|
if (this.status === status) return;
|
||||||
|
this.status = status;
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(): void {
|
||||||
|
this.emit("update", this.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(text: string): void {
|
||||||
|
this.emit("notice", { serverId: this.serverId, textChannelId: this.textChannelId, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ connection ---
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return Boolean(this.connection?.connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(channelId: string, channelName: string | null): Promise<void> {
|
||||||
|
if (this.connection?.connected && this.voiceChannelId === channelId) {
|
||||||
|
this.voiceChannelName = channelName ?? this.voiceChannelName;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.connection) await this.leaveVoice();
|
||||||
|
|
||||||
|
this.setStatus("connecting");
|
||||||
|
this.log.info({ channelId }, "joining voice channel");
|
||||||
|
|
||||||
|
const connection = await this.revoice.join(channelId);
|
||||||
|
if (!connection.connected) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")),
|
||||||
|
JOIN_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
connection.on("join", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connection = connection;
|
||||||
|
this.voiceChannelId = channelId;
|
||||||
|
this.voiceChannelName = channelName;
|
||||||
|
|
||||||
|
connection.on("userleave", () => this.checkEmptyChannel());
|
||||||
|
connection.on("userLeave", () => this.checkEmptyChannel());
|
||||||
|
connection.on("userJoin", () => this.clearIdleTimer());
|
||||||
|
|
||||||
|
const media = new MediaPlayer(true);
|
||||||
|
media.on("startplay", () => {
|
||||||
|
this.setStatus(media.paused ? "paused" : "playing");
|
||||||
|
this.startTicker();
|
||||||
|
});
|
||||||
|
media.on("buffer", () => this.setStatus("buffering"));
|
||||||
|
media.on("pause", () => this.setStatus("paused"));
|
||||||
|
media.on("unpause", () => this.setStatus("playing"));
|
||||||
|
media.on("finish", () => {
|
||||||
|
void this.handleFinish();
|
||||||
|
});
|
||||||
|
this.media = media;
|
||||||
|
await connection.play(media);
|
||||||
|
|
||||||
|
this.setStatus("idle");
|
||||||
|
this.log.info({ channelId }, "voice connection established");
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveVoice(): Promise<void> {
|
||||||
|
this.clearIdleTimer();
|
||||||
|
this.stopTicker();
|
||||||
|
this.teardownPlayback();
|
||||||
|
this.current = null;
|
||||||
|
|
||||||
|
const connection = this.connection;
|
||||||
|
this.connection = null;
|
||||||
|
this.media?.removeAllListeners();
|
||||||
|
this.media = null;
|
||||||
|
this.voiceChannelId = null;
|
||||||
|
this.voiceChannelName = null;
|
||||||
|
|
||||||
|
if (connection) {
|
||||||
|
try {
|
||||||
|
await connection.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
this.log.warn({ err }, "failed to leave voice channel cleanly");
|
||||||
|
}
|
||||||
|
connection.removeAllListeners();
|
||||||
|
}
|
||||||
|
this.setStatus("idle");
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkEmptyChannel(): void {
|
||||||
|
if (!this.connection) return;
|
||||||
|
if (this.connection.getUsers().length > 0) return;
|
||||||
|
this.startIdleTimer("В канале никого не осталось");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- playback ---
|
||||||
|
|
||||||
|
private assertReady(): MediaPlayerLike {
|
||||||
|
if (!this.media || !this.connection?.connected) {
|
||||||
|
throw new UserFacingError("Бот не подключён к голосовому каналу");
|
||||||
|
}
|
||||||
|
return this.media;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(tracks: Track[], position?: number): void {
|
||||||
|
if (this.queue.length + tracks.length > config.MAX_QUEUE_SIZE) {
|
||||||
|
throw new UserFacingError(`Очередь ограничена ${config.MAX_QUEUE_SIZE} треками`);
|
||||||
|
}
|
||||||
|
if (position === undefined) this.queue.push(...tracks);
|
||||||
|
else this.queue.splice(Math.max(0, position), 0, ...tracks);
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts playback if nothing is currently playing. */
|
||||||
|
async ensurePlaying(): Promise<void> {
|
||||||
|
if (this.current || this.status === "buffering" || this.status === "connecting") return;
|
||||||
|
await this.advance(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startPlayback(track: Track, seekSeconds = 0): Promise<void> {
|
||||||
|
const media = this.assertReady();
|
||||||
|
this.clearIdleTimer();
|
||||||
|
this.teardownPlayback();
|
||||||
|
|
||||||
|
this.current = track;
|
||||||
|
this.seekOffset = seekSeconds;
|
||||||
|
this.setStatus("buffering");
|
||||||
|
this.publish();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = await openPlayback(track, seekSeconds);
|
||||||
|
this.currentInput = input;
|
||||||
|
this.expectingStop = false;
|
||||||
|
await media.playStream(input.input, input.inputOptions);
|
||||||
|
// stop() rebuilds the volume transformer, so volume is applied per track.
|
||||||
|
media.setVolume(this.volume / 100);
|
||||||
|
this.startTicker();
|
||||||
|
} catch (err) {
|
||||||
|
this.log.warn({ err, track: track.title }, "playback failed");
|
||||||
|
const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка";
|
||||||
|
this.notify(`⚠️ Не удалось воспроизвести **${track.title}** (${message}), пропускаю.`);
|
||||||
|
this.current = null;
|
||||||
|
await this.advance(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tears down ffmpeg/yt-dlp for the current track without advancing the queue. */
|
||||||
|
private teardownPlayback(): void {
|
||||||
|
if (this.media) {
|
||||||
|
this.expectingStop = true;
|
||||||
|
try {
|
||||||
|
this.media.fProc?.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// ffmpeg may already be gone.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.media.stop();
|
||||||
|
} catch (err) {
|
||||||
|
this.log.debug({ err }, "media.stop() threw");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.currentInput?.cleanup();
|
||||||
|
this.currentInput = null;
|
||||||
|
this.seekOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleFinish(): Promise<void> {
|
||||||
|
if (this.expectingStop) {
|
||||||
|
this.expectingStop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.advance(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves to the next track. `skipLoop` ignores per-track looping (used by skip). */
|
||||||
|
private async advance(skipLoop: boolean): Promise<void> {
|
||||||
|
const finished = this.current;
|
||||||
|
this.current = null;
|
||||||
|
this.currentInput?.cleanup();
|
||||||
|
this.currentInput = null;
|
||||||
|
this.seekOffset = 0;
|
||||||
|
|
||||||
|
if (finished) {
|
||||||
|
this.history.unshift(finished);
|
||||||
|
this.history = this.history.slice(0, HISTORY_LIMIT);
|
||||||
|
if (!skipLoop && this.loop === "track") this.queue.unshift(finished);
|
||||||
|
else if (this.loop === "queue") this.queue.push(finished);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (!next) {
|
||||||
|
this.stopTicker();
|
||||||
|
this.setStatus("idle");
|
||||||
|
this.publish();
|
||||||
|
if (finished) this.notify("⏹️ Очередь закончилась.");
|
||||||
|
this.startIdleTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.startPlayback(next);
|
||||||
|
this.notify(`▶️ Сейчас играет: **${next.title}**`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async skip(count = 1): Promise<Track | null> {
|
||||||
|
if (!this.current && this.queue.length === 0) throw new UserFacingError("Нечего пропускать");
|
||||||
|
for (let i = 1; i < count; i += 1) this.queue.shift();
|
||||||
|
this.teardownPlayback();
|
||||||
|
await this.advance(true);
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.queue = [];
|
||||||
|
this.loop = "off";
|
||||||
|
this.teardownPlayback();
|
||||||
|
this.current = null;
|
||||||
|
this.stopTicker();
|
||||||
|
this.setStatus("idle");
|
||||||
|
this.publish();
|
||||||
|
this.startIdleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
const media = this.assertReady();
|
||||||
|
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
|
||||||
|
media.pause();
|
||||||
|
this.setStatus("paused");
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(): void {
|
||||||
|
const media = this.assertReady();
|
||||||
|
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
|
||||||
|
media.resume();
|
||||||
|
this.setStatus("playing");
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
const clamped = Math.min(200, Math.max(0, Math.round(volume)));
|
||||||
|
this.volume = clamped;
|
||||||
|
this.media?.setVolume(clamped / 100);
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoop(mode: LoopMode): void {
|
||||||
|
this.loop = mode;
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
shuffle(): void {
|
||||||
|
for (let i = this.queue.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
const a = this.queue[i];
|
||||||
|
const b = this.queue[j];
|
||||||
|
if (a && b) {
|
||||||
|
this.queue[i] = b;
|
||||||
|
this.queue[j] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.shuffleUsed = true;
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(trackId: string): Track {
|
||||||
|
const index = this.queue.findIndex((track) => track.id === trackId);
|
||||||
|
if (index === -1) throw new UserFacingError("Трек не найден в очереди");
|
||||||
|
const [removed] = this.queue.splice(index, 1);
|
||||||
|
this.publish();
|
||||||
|
return removed as Track;
|
||||||
|
}
|
||||||
|
|
||||||
|
move(trackId: string, toIndex: number): void {
|
||||||
|
const from = this.queue.findIndex((track) => track.id === trackId);
|
||||||
|
if (from === -1) throw new UserFacingError("Трек не найден в очереди");
|
||||||
|
const target = Math.min(this.queue.length - 1, Math.max(0, toIndex));
|
||||||
|
const [track] = this.queue.splice(from, 1);
|
||||||
|
if (track) this.queue.splice(target, 0, track);
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearQueue(): void {
|
||||||
|
this.queue = [];
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(seconds: number): Promise<void> {
|
||||||
|
const track = this.current;
|
||||||
|
if (!track) throw new UserFacingError("Сейчас ничего не играет");
|
||||||
|
if (track.isLive) throw new UserFacingError("Нельзя перематывать прямой эфир");
|
||||||
|
if (track.duration > 0 && seconds >= track.duration) {
|
||||||
|
throw new UserFacingError("Позиция за пределами трека");
|
||||||
|
}
|
||||||
|
this.teardownPlayback();
|
||||||
|
await this.startPlayback(track, Math.max(0, seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
async playNow(tracks: Track[]): Promise<void> {
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
this.queue.unshift(...tracks);
|
||||||
|
this.teardownPlayback();
|
||||||
|
await this.advance(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- housekeeping ---
|
||||||
|
|
||||||
|
private startTicker(): void {
|
||||||
|
if (this.ticker) return;
|
||||||
|
this.ticker = setInterval(() => {
|
||||||
|
if (!this.current || !this.media) return;
|
||||||
|
// ffmpeg reports the real duration once it has probed the input, which is
|
||||||
|
// the only way we learn how long a local file or a direct URL is.
|
||||||
|
if (this.current.duration === 0 && !this.current.isLive) {
|
||||||
|
const probed = parseFfmpegDuration(this.media.codecData?.duration);
|
||||||
|
if (probed > 0) {
|
||||||
|
this.current.duration = Math.round(probed);
|
||||||
|
this.publish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit("position", {
|
||||||
|
serverId: this.serverId,
|
||||||
|
position: Math.round(this.position * 10) / 10,
|
||||||
|
duration: this.current.duration,
|
||||||
|
status: this.status,
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
this.ticker.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTicker(): void {
|
||||||
|
if (!this.ticker) return;
|
||||||
|
clearInterval(this.ticker);
|
||||||
|
this.ticker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearIdleTimer(): void {
|
||||||
|
if (!this.idleTimer) return;
|
||||||
|
clearTimeout(this.idleTimer);
|
||||||
|
this.idleTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startIdleTimer(reason?: string): void {
|
||||||
|
this.clearIdleTimer();
|
||||||
|
if (config.IDLE_TIMEOUT_SECONDS <= 0 || !this.connection) return;
|
||||||
|
this.idleTimer = setTimeout(() => {
|
||||||
|
if (this.current) return;
|
||||||
|
this.notify(`👋 ${reason ?? "Нет активности"}, выхожу из голосового канала.`);
|
||||||
|
void this.leaveVoice();
|
||||||
|
}, config.IDLE_TIMEOUT_SECONDS * 1000);
|
||||||
|
this.idleTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
await this.leaveVoice();
|
||||||
|
this.emit("destroyed", { serverId: this.serverId });
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
// revoice.js is CommonJS and its bundled typings lag behind the LiveKit rewrite,
|
||||||
|
// so we load it through require() and describe only the surface we rely on.
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
export interface MediaPlayerLike {
|
||||||
|
readonly seconds: number;
|
||||||
|
readonly duration: number;
|
||||||
|
codecData?: { duration?: string } | null;
|
||||||
|
paused: boolean;
|
||||||
|
playing: boolean;
|
||||||
|
fProc?: { kill(signal?: string): void } | null;
|
||||||
|
originStream?: { destroy(): void } | null;
|
||||||
|
playStream(input: Readable | string, inputOptions?: string[]): Promise<void>;
|
||||||
|
pause(): void;
|
||||||
|
resume(): void;
|
||||||
|
stop(init?: boolean): void;
|
||||||
|
destroy(): void;
|
||||||
|
setVolume(volume: number): void;
|
||||||
|
on(event: "start" | "startplay" | "buffer" | "pause" | "unpause" | "finish", listener: () => void): this;
|
||||||
|
removeAllListeners(event?: string): this;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceConnectionLike {
|
||||||
|
readonly connected: boolean;
|
||||||
|
channelId: string;
|
||||||
|
play(media: MediaPlayerLike): Promise<void>;
|
||||||
|
leave(): Promise<void>;
|
||||||
|
destroy(): Promise<void>;
|
||||||
|
getUsers(): Array<{ id: string }>;
|
||||||
|
on(event: "join" | "leave" | "roomfetched" | "autoleave", listener: () => void): this;
|
||||||
|
on(event: "state", listener: (state: string) => void): this;
|
||||||
|
on(event: "userJoin" | "userleave" | "userLeave", listener: (user: { id: string }) => void): this;
|
||||||
|
removeAllListeners(event?: string): this;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevoiceLike {
|
||||||
|
join(channelId: string, leaveIfEmpty?: boolean | number): Promise<VoiceConnectionLike>;
|
||||||
|
getVoiceConnection(channelId: string): VoiceConnectionLike | undefined;
|
||||||
|
connections: Map<string, VoiceConnectionLike>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RevoiceModule {
|
||||||
|
Revoice: new (token: string, apiConfig?: Record<string, unknown>) => RevoiceLike;
|
||||||
|
MediaPlayer: new (normalisation?: boolean) => MediaPlayerLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revoice = require("revoice.js") as RevoiceModule;
|
||||||
|
|
||||||
|
export const Revoice = revoice.Revoice;
|
||||||
|
export const MediaPlayer = revoice.MediaPlayer;
|
||||||
|
|
||||||
|
/** Parses ffmpeg's `hh:mm:ss.xx` duration into seconds. */
|
||||||
|
export function parseFfmpegDuration(value: string | undefined | null): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
const parts = value.split(":").map((part) => Number.parseFloat(part));
|
||||||
|
if (parts.some((part) => Number.isNaN(part))) return 0;
|
||||||
|
return parts.reduce((acc, part) => acc * 60 + part, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { startApiServer } from "./api/server.js";
|
||||||
|
import { startBot } from "./bot/index.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { MusicManager } from "./core/manager.js";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
import { checkYtDlp } from "./sources/index.js";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const ytdlpVersion = await checkYtDlp();
|
||||||
|
if (ytdlpVersion) {
|
||||||
|
logger.info({ version: ytdlpVersion }, "yt-dlp detected");
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
{ path: config.YTDLP_PATH },
|
||||||
|
"yt-dlp not found — YouTube/SoundCloud playback will fail; only direct links and local files will work",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new MusicManager();
|
||||||
|
const bot = await startBot(manager);
|
||||||
|
const app = await startApiServer({ manager, context: bot.context });
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
logger.info({ signal }, "shutting down");
|
||||||
|
try {
|
||||||
|
await app.close();
|
||||||
|
await bot.stop();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "shutdown failed");
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
process.on("unhandledRejection", (err) => logger.error({ err }, "unhandled rejection"));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
logger.fatal({ err }, "failed to start");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: config.LOG_LEVEL,
|
||||||
|
...(config.isProduction
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Logger = typeof logger;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import type { Requester, Track } from "../types.js";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "direct" });
|
||||||
|
|
||||||
|
const AUDIO_CONTENT_TYPES = [
|
||||||
|
"audio/",
|
||||||
|
"application/ogg",
|
||||||
|
"application/x-mpegurl",
|
||||||
|
"application/vnd.apple.mpegurl",
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ProbeResult {
|
||||||
|
isMedia: boolean;
|
||||||
|
isLive: boolean;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap HEAD probe used to tell "direct media URL" apart from "web page". */
|
||||||
|
export async function probe(url: string): Promise<ProbeResult> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "HEAD",
|
||||||
|
redirect: "follow",
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { "user-agent": "stoat-mbot/0.1", icy: "1" },
|
||||||
|
});
|
||||||
|
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
|
||||||
|
const isMedia = AUDIO_CONTENT_TYPES.some((type) => contentType.startsWith(type));
|
||||||
|
// Shoutcast/Icecast expose the station name and never a content length.
|
||||||
|
const icyName = res.headers.get("icy-name");
|
||||||
|
const isLive = isMedia && !res.headers.get("content-length");
|
||||||
|
return { isMedia: isMedia || Boolean(icyName), isLive: isLive || Boolean(icyName), title: icyName };
|
||||||
|
} catch (err) {
|
||||||
|
log.debug({ err, url }, "probe failed");
|
||||||
|
return { isMedia: false, isLive: false, title: null };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTrack(url: string, requestedBy: Requester, probed: ProbeResult): Track {
|
||||||
|
let title = probed.title;
|
||||||
|
if (!title) {
|
||||||
|
try {
|
||||||
|
const name = path.basename(new URL(url).pathname);
|
||||||
|
title = decodeURIComponent(name) || new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
title = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let host: string | null = null;
|
||||||
|
try {
|
||||||
|
host = new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
host = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
title,
|
||||||
|
author: host,
|
||||||
|
duration: 0,
|
||||||
|
isLive: probed.isLive,
|
||||||
|
url,
|
||||||
|
thumbnail: null,
|
||||||
|
source: "direct",
|
||||||
|
requestedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { UserFacingError, type Requester, type SearchResult, type Track } from "../types.js";
|
||||||
|
import * as direct from "./direct.js";
|
||||||
|
import * as local from "./local.js";
|
||||||
|
import * as ytdlp from "./ytdlp.js";
|
||||||
|
|
||||||
|
export { checkAvailable as checkYtDlp } from "./ytdlp.js";
|
||||||
|
export { isEnabled as isLocalLibraryEnabled, listFiles as listLocalFiles } from "./local.js";
|
||||||
|
|
||||||
|
const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"];
|
||||||
|
const SOUNDCLOUD_HOSTS = ["soundcloud.com", "on.soundcloud.com", "m.soundcloud.com"];
|
||||||
|
|
||||||
|
function asUrl(value: string): URL | null {
|
||||||
|
if (!/^https?:\/\//i.test(value)) return null;
|
||||||
|
try {
|
||||||
|
return new URL(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostMatches(url: URL, hosts: string[]): boolean {
|
||||||
|
const host = url.hostname.replace(/^www\./, "");
|
||||||
|
return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedQuery {
|
||||||
|
text: string;
|
||||||
|
forced: "youtube" | "soundcloud" | "local" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrefix(raw: string): ParsedQuery {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const match = /^(yt|youtube|sc|soundcloud|local|file):\s*(.+)$/is.exec(trimmed);
|
||||||
|
if (!match) return { text: trimmed, forced: null };
|
||||||
|
const [, prefix, rest] = match as unknown as [string, string, string];
|
||||||
|
const key = prefix.toLowerCase();
|
||||||
|
if (key === "sc" || key === "soundcloud") return { text: rest.trim(), forced: "soundcloud" };
|
||||||
|
if (key === "local" || key === "file") return { text: rest.trim(), forced: "local" };
|
||||||
|
return { text: rest.trim(), forced: "youtube" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns whatever a user typed into a playable set of tracks. */
|
||||||
|
export async function resolveQuery(
|
||||||
|
rawQuery: string,
|
||||||
|
requestedBy: Requester,
|
||||||
|
maxTracks = config.MAX_QUEUE_SIZE,
|
||||||
|
): Promise<SearchResult> {
|
||||||
|
const { text, forced } = parsePrefix(rawQuery);
|
||||||
|
if (!text) throw new UserFacingError("Укажите название трека или ссылку");
|
||||||
|
|
||||||
|
if (forced === "local") {
|
||||||
|
const tracks = await local.search(text, config.SEARCH_RESULT_LIMIT, requestedBy);
|
||||||
|
if (tracks.length === 0) throw new UserFacingError("В медиатеке ничего не найдено");
|
||||||
|
return { tracks: tracks.slice(0, 1), playlist: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = asUrl(text);
|
||||||
|
if (url) {
|
||||||
|
if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) {
|
||||||
|
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
|
||||||
|
}
|
||||||
|
const probed = await direct.probe(text);
|
||||||
|
if (probed.isMedia) {
|
||||||
|
return { tracks: [direct.toTrack(text, requestedBy, probed)], playlist: null };
|
||||||
|
}
|
||||||
|
// Not a raw media URL — let yt-dlp try its extractors (Bandcamp, Vimeo, ...).
|
||||||
|
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.isEnabled() && forced === null) {
|
||||||
|
const localHits = await local.search(text, 1, requestedBy);
|
||||||
|
if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy);
|
||||||
|
if (tracks.length === 0) throw new UserFacingError("Ничего не найдено");
|
||||||
|
return { tracks, playlist: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-result search used by the `search` command and the web panel. */
|
||||||
|
export async function searchTracks(
|
||||||
|
rawQuery: string,
|
||||||
|
requestedBy: Requester,
|
||||||
|
limit = config.SEARCH_RESULT_LIMIT,
|
||||||
|
): Promise<Track[]> {
|
||||||
|
const { text, forced } = parsePrefix(rawQuery);
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const url = asUrl(text);
|
||||||
|
if (url) {
|
||||||
|
const result = await resolveQuery(text, requestedBy);
|
||||||
|
return result.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forced === "local") return local.search(text, limit, requestedBy);
|
||||||
|
|
||||||
|
const [remote, localHits] = await Promise.all([
|
||||||
|
ytdlp.search(text, forced ?? "youtube", limit, requestedBy),
|
||||||
|
local.isEnabled() && forced === null
|
||||||
|
? local.search(text, 3, requestedBy).catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
return [...localHits, ...remote].slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackInput {
|
||||||
|
/** Either a file path / URL for ffmpeg, or a piped stream. */
|
||||||
|
input: string | Readable;
|
||||||
|
inputOptions: string[];
|
||||||
|
cleanup(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTTP_RESILIENCE = [
|
||||||
|
"-reconnect", "1",
|
||||||
|
"-reconnect_streamed", "1",
|
||||||
|
"-reconnect_delay_max", "5",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */
|
||||||
|
export async function openPlayback(track: Track, seekSeconds = 0): Promise<PlaybackInput> {
|
||||||
|
const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
|
||||||
|
|
||||||
|
if (track.source === "local") {
|
||||||
|
const filePath = await local.assertInsideLibrary(track.url);
|
||||||
|
return { input: filePath, inputOptions: seekOptions, cleanup: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.source === "direct") {
|
||||||
|
return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seekSeconds > 0) {
|
||||||
|
// Seeking over a pipe would mean decoding everything up to the offset, so we
|
||||||
|
// resolve the CDN URL instead and let ffmpeg do an HTTP range request.
|
||||||
|
const streamUrl = await ytdlp.resolveStreamUrl(track.url);
|
||||||
|
return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = ytdlp.openAudioStream(track.url);
|
||||||
|
return { input: proc.stream, inputOptions: [], cleanup: () => proc.kill() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readdir, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { UserFacingError, type Requester, type Track } from "../types.js";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "local" });
|
||||||
|
const AUDIO_EXTENSIONS = new Set([".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav", ".wma", ".webm"]);
|
||||||
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
let cache: { files: string[]; at: number } | null = null;
|
||||||
|
|
||||||
|
function libraryRoot(): string {
|
||||||
|
if (!config.LOCAL_MEDIA_DIR) throw new UserFacingError("Локальная медиатека не настроена (LOCAL_MEDIA_DIR)");
|
||||||
|
return path.resolve(config.LOCAL_MEDIA_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walk(dir: string, out: string[], depth = 0): Promise<void> {
|
||||||
|
if (depth > 6) return;
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, dir }, "cannot read media directory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await walk(full, out, depth + 1);
|
||||||
|
} else if (entry.isFile() && AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFiles(force = false): Promise<string[]> {
|
||||||
|
const root = libraryRoot();
|
||||||
|
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.files;
|
||||||
|
const files: string[] = [];
|
||||||
|
await walk(root, files);
|
||||||
|
files.sort((a, b) => a.localeCompare(b));
|
||||||
|
cache = { files, at: Date.now() };
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEnabled(): boolean {
|
||||||
|
return Boolean(config.LOCAL_MEDIA_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guards against path traversal — only files inside the library may be played. */
|
||||||
|
export async function assertInsideLibrary(filePath: string): Promise<string> {
|
||||||
|
const root = libraryRoot();
|
||||||
|
const resolved = path.resolve(filePath);
|
||||||
|
const relative = path.relative(root, resolved);
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||||
|
throw new UserFacingError("Файл вне медиатеки");
|
||||||
|
}
|
||||||
|
const info = await stat(resolved).catch(() => null);
|
||||||
|
if (!info?.isFile()) throw new UserFacingError("Файл не найден");
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTrack(filePath: string, requestedBy: Requester): Track {
|
||||||
|
const root = libraryRoot();
|
||||||
|
const relative = path.relative(root, filePath);
|
||||||
|
const parsed = path.parse(relative);
|
||||||
|
const parentDir = path.basename(parsed.dir);
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
title: parsed.name,
|
||||||
|
author: parentDir || null,
|
||||||
|
// Filled in from ffmpeg's codecData once playback starts.
|
||||||
|
duration: 0,
|
||||||
|
isLive: false,
|
||||||
|
url: filePath,
|
||||||
|
thumbnail: null,
|
||||||
|
source: "local",
|
||||||
|
requestedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(query: string, limit: number, requestedBy: Requester): Promise<Track[]> {
|
||||||
|
const files = await listFiles();
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
const matches = needle
|
||||||
|
? files.filter((file) => path.basename(file).toLowerCase().includes(needle))
|
||||||
|
: files;
|
||||||
|
return matches.slice(0, limit).map((file) => toTrack(file, requestedBy));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePath(filePath: string, requestedBy: Requester): Promise<Track> {
|
||||||
|
const resolved = await assertInsideLibrary(filePath);
|
||||||
|
return toTrack(resolved, requestedBy);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { UserFacingError, type Requester, type SearchResult, type SourceKind, type Track } from "../types.js";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "yt-dlp" });
|
||||||
|
|
||||||
|
/** Raw shape of the fields we consume from yt-dlp's JSON output. */
|
||||||
|
interface YtDlpEntry {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
duration?: number | null;
|
||||||
|
uploader?: string | null;
|
||||||
|
channel?: string | null;
|
||||||
|
artist?: string | null;
|
||||||
|
webpage_url?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
original_url?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
thumbnails?: Array<{ url?: string }> | null;
|
||||||
|
is_live?: boolean | null;
|
||||||
|
live_status?: string | null;
|
||||||
|
extractor_key?: string | null;
|
||||||
|
ie_key?: string | null;
|
||||||
|
_type?: string;
|
||||||
|
entries?: YtDlpEntry[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseArgs(): string[] {
|
||||||
|
const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"];
|
||||||
|
if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
reject(new UserFacingError("yt-dlp не ответил вовремя"));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk: string) => (stdout += chunk));
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stderr.on("data", (chunk: string) => (stderr += chunk));
|
||||||
|
|
||||||
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
reject(new UserFacingError(`yt-dlp не найден (${config.YTDLP_PATH}). Проверьте YTDLP_PATH.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code === 0 || stdout.trim().length > 0) {
|
||||||
|
resolve(stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
|
||||||
|
reject(new UserFacingError(firstUsefulError(stderr)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstUsefulError(stderr: string): string {
|
||||||
|
const line = stderr
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.find((l) => l.toUpperCase().startsWith("ERROR"));
|
||||||
|
if (!line) return "Не удалось получить трек";
|
||||||
|
return line.replace(/^ERROR:\s*/i, "").slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceOf(entry: YtDlpEntry): SourceKind {
|
||||||
|
const key = (entry.extractor_key ?? entry.ie_key ?? "").toLowerCase();
|
||||||
|
if (key.includes("soundcloud")) return "soundcloud";
|
||||||
|
if (key.includes("youtube")) return "youtube";
|
||||||
|
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
|
||||||
|
if (url.includes("soundcloud.com")) return "soundcloud";
|
||||||
|
if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube";
|
||||||
|
return "direct";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickThumbnail(entry: YtDlpEntry): string | null {
|
||||||
|
if (entry.thumbnail) return entry.thumbnail;
|
||||||
|
const list = entry.thumbnails ?? [];
|
||||||
|
const last = list.at(-1);
|
||||||
|
return last?.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTrack(entry: YtDlpEntry, requestedBy: Requester, fallbackSource?: SourceKind): Track {
|
||||||
|
const isLive = Boolean(entry.is_live) || entry.live_status === "is_live";
|
||||||
|
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
title: entry.title?.trim() || "Без названия",
|
||||||
|
author: entry.artist ?? entry.uploader ?? entry.channel ?? null,
|
||||||
|
duration: isLive ? 0 : Math.max(0, Math.round(entry.duration ?? 0)),
|
||||||
|
isLive,
|
||||||
|
url,
|
||||||
|
thumbnail: pickThumbnail(entry),
|
||||||
|
source: fallbackSource ?? sourceOf(entry),
|
||||||
|
requestedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNdjson(stdout: string): YtDlpEntry[] {
|
||||||
|
const entries: YtDlpEntry[] = [];
|
||||||
|
for (const line of stdout.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("{")) continue;
|
||||||
|
try {
|
||||||
|
entries.push(JSON.parse(trimmed) as YtDlpEntry);
|
||||||
|
} catch {
|
||||||
|
// yt-dlp occasionally interleaves non-JSON noise; skip it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(
|
||||||
|
query: string,
|
||||||
|
kind: "youtube" | "soundcloud",
|
||||||
|
limit: number,
|
||||||
|
requestedBy: Requester,
|
||||||
|
): Promise<Track[]> {
|
||||||
|
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
|
||||||
|
const stdout = await runYtDlp([
|
||||||
|
...baseArgs(),
|
||||||
|
"--flat-playlist",
|
||||||
|
"--dump-json",
|
||||||
|
`${prefix}${limit}:${query}`,
|
||||||
|
]);
|
||||||
|
return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a URL that may point at a single track, a playlist, or an album. */
|
||||||
|
export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> {
|
||||||
|
const stdout = await runYtDlp([
|
||||||
|
...baseArgs(),
|
||||||
|
"--flat-playlist",
|
||||||
|
"--dump-single-json",
|
||||||
|
"--playlist-end",
|
||||||
|
String(maxTracks),
|
||||||
|
url,
|
||||||
|
]);
|
||||||
|
const root = parseNdjson(stdout)[0];
|
||||||
|
if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp");
|
||||||
|
|
||||||
|
if (root._type === "playlist" && Array.isArray(root.entries)) {
|
||||||
|
const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e));
|
||||||
|
if (entries.length === 0) throw new UserFacingError("Плейлист пуст или недоступен");
|
||||||
|
return {
|
||||||
|
tracks: entries.map((entry) => toTrack(entry, requestedBy)),
|
||||||
|
playlist: {
|
||||||
|
title: root.title?.trim() || "Плейлист",
|
||||||
|
url: root.webpage_url ?? url,
|
||||||
|
trackCount: entries.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tracks: [toTrack(root, requestedBy)], playlist: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
|
||||||
|
export async function resolveStreamUrl(pageUrl: string): Promise<string> {
|
||||||
|
const stdout = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]);
|
||||||
|
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
||||||
|
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioProcess {
|
||||||
|
stream: Readable;
|
||||||
|
kill(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
|
||||||
|
export function openAudioStream(pageUrl: string): AudioProcess {
|
||||||
|
const child = spawn(
|
||||||
|
config.YTDLP_PATH,
|
||||||
|
[...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
let stderr = "";
|
||||||
|
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()) {
|
||||||
|
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: child.stdout,
|
||||||
|
kill: () => {
|
||||||
|
if (child.exitCode === null) child.kill("SIGKILL");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAvailable(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const out = await runYtDlp(["--version"], 15_000);
|
||||||
|
return out.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { config } from "../config.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
|
import { UserFacingError } from "../types.js";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "stoat-rest" });
|
||||||
|
|
||||||
|
export interface StoatUser {
|
||||||
|
_id: string;
|
||||||
|
username: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
discriminator?: string;
|
||||||
|
avatar?: { _id: string } | null;
|
||||||
|
bot?: { owner: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoatMember {
|
||||||
|
_id: { server: string; user: string };
|
||||||
|
nickname?: string | null;
|
||||||
|
roles?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoatServer {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
owner: string;
|
||||||
|
roles?: Record<string, { name: string; rank?: number }> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginResult =
|
||||||
|
| { kind: "success"; token: string; userId: string }
|
||||||
|
| { kind: "mfa"; ticket: string; methods: string[] };
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit & { token?: { type: "bot" | "session"; value: string } } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { token, headers, ...rest } = init;
|
||||||
|
const finalHeaders: Record<string, string> = {
|
||||||
|
accept: "application/json",
|
||||||
|
...(headers as Record<string, string> | undefined),
|
||||||
|
};
|
||||||
|
if (token?.type === "bot") finalHeaders["x-bot-token"] = token.value;
|
||||||
|
if (token?.type === "session") finalHeaders["x-session-token"] = token.value;
|
||||||
|
if (rest.body && !finalHeaders["content-type"]) finalHeaders["content-type"] = "application/json";
|
||||||
|
|
||||||
|
const res = await fetch(`${config.STOAT_API_URL}${path}`, { ...rest, headers: finalHeaders });
|
||||||
|
const text = await res.text();
|
||||||
|
const body = text ? (JSON.parse(text) as unknown) : null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
log.debug({ path, status: res.status, body }, "stoat api error");
|
||||||
|
const type = (body as { type?: string } | null)?.type;
|
||||||
|
throw new StoatApiError(res.status, type ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StoatApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly type: string,
|
||||||
|
) {
|
||||||
|
super(`Stoat API error: ${type}`);
|
||||||
|
this.name = "StoatApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a panel user against the very same accounts the Stoat instance
|
||||||
|
* uses. We never see or store password material: the credentials go straight to
|
||||||
|
* the instance's auth endpoint and the short-lived session is revoked as soon as
|
||||||
|
* we have confirmed who the user is.
|
||||||
|
*/
|
||||||
|
export async function loginWithPassword(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
mfa?: { ticket: string; totpCode?: string; recoveryCode?: string },
|
||||||
|
): Promise<LoginResult> {
|
||||||
|
const body: Record<string, unknown> = { friendly_name: "stoat-mbot panel" };
|
||||||
|
if (mfa) {
|
||||||
|
body["mfa_ticket"] = mfa.ticket;
|
||||||
|
body["mfa_response"] = mfa.totpCode
|
||||||
|
? { totp_code: mfa.totpCode }
|
||||||
|
: { recovery_code: mfa.recoveryCode };
|
||||||
|
} else {
|
||||||
|
body["email"] = email;
|
||||||
|
body["password"] = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: { result: string; token?: string; user_id?: string; ticket?: string; allowed_methods?: string[] };
|
||||||
|
try {
|
||||||
|
response = await request("/auth/session/login", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StoatApiError && (err.status === 401 || err.status === 400)) {
|
||||||
|
throw new UserFacingError("Неверный логин или пароль");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.result === "MFA") {
|
||||||
|
return {
|
||||||
|
kind: "mfa",
|
||||||
|
ticket: response.ticket ?? "",
|
||||||
|
methods: response.allowed_methods ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (response.result === "Disabled") throw new UserFacingError("Аккаунт отключён");
|
||||||
|
if (!response.token || !response.user_id) throw new UserFacingError("Неожиданный ответ сервера авторизации");
|
||||||
|
return { kind: "success", token: response.token, userId: response.user_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSelf(sessionToken: string): Promise<StoatUser> {
|
||||||
|
return request<StoatUser>("/users/@me", { token: { type: "session", value: sessionToken } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeSession(sessionToken: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await request("/auth/session/logout", {
|
||||||
|
method: "POST",
|
||||||
|
token: { type: "session", value: sessionToken },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err }, "could not revoke temporary session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUser(userId: string): Promise<StoatUser> {
|
||||||
|
return request<StoatUser>(`/users/${userId}`, {
|
||||||
|
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchServer(serverId: string): Promise<StoatServer> {
|
||||||
|
return request<StoatServer>(`/servers/${serverId}`, {
|
||||||
|
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMember(serverId: string, userId: string): Promise<StoatMember | null> {
|
||||||
|
try {
|
||||||
|
return await request<StoatMember>(`/servers/${serverId}/members/${userId}`, {
|
||||||
|
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StoatApiError && err.status === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export type SourceKind = "youtube" | "soundcloud" | "direct" | "local";
|
||||||
|
|
||||||
|
export interface Requester {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
/** Stable id used by the web UI to address a queue entry. */
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string | null;
|
||||||
|
/** Seconds; 0 for live streams. */
|
||||||
|
duration: number;
|
||||||
|
isLive: boolean;
|
||||||
|
/** Human-facing page URL (or file path for local tracks). */
|
||||||
|
url: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
source: SourceKind;
|
||||||
|
requestedBy: Requester;
|
||||||
|
/** Direct media URL; resolved lazily right before playback. */
|
||||||
|
streamUrl?: string;
|
||||||
|
/** When streamUrl was resolved — CDN links expire, so we refresh them. */
|
||||||
|
streamUrlResolvedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoopMode = "off" | "track" | "queue";
|
||||||
|
|
||||||
|
export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused";
|
||||||
|
|
||||||
|
export interface PlayerSnapshot {
|
||||||
|
serverId: string;
|
||||||
|
serverName: string | null;
|
||||||
|
voiceChannelId: string | null;
|
||||||
|
voiceChannelName: string | null;
|
||||||
|
textChannelId: string | null;
|
||||||
|
status: PlayerStatus;
|
||||||
|
current: Track | null;
|
||||||
|
/** Playback position of the current track, in seconds. */
|
||||||
|
position: number;
|
||||||
|
queue: Track[];
|
||||||
|
history: Track[];
|
||||||
|
volume: number;
|
||||||
|
loop: LoopMode;
|
||||||
|
shuffleUsed: boolean;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
tracks: Track[];
|
||||||
|
/** Set when a URL resolved to a playlist/album. */
|
||||||
|
playlist: { title: string; url: string; trackCount: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserFacingError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "UserFacingError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "web"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Stoat Music</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1841
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "stoat-mbot-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { Login } from "./components/Login";
|
||||||
|
import { NowPlaying } from "./components/NowPlaying";
|
||||||
|
import { QueueList } from "./components/QueueList";
|
||||||
|
import { SearchPanel } from "./components/SearchPanel";
|
||||||
|
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
|
||||||
|
|
||||||
|
const SERVER_KEY = "mbot.server";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [serverId, setServerId] = useState<string | null>(null);
|
||||||
|
const [state, setState] = useState<PlayerState | null>(null);
|
||||||
|
const [position, setPosition] = useState(0);
|
||||||
|
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
|
||||||
|
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
|
||||||
|
const [canControl, setCanControl] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
/** Consumes the one-time link issued by the `!panel` chat command. */
|
||||||
|
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const token = params.get("token");
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const result = await api.loginWithLink(token);
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
return result.serverId;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ссылка недействительна");
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMe = useCallback(
|
||||||
|
async (preferredServer?: string | null) => {
|
||||||
|
try {
|
||||||
|
const profile = await api.me();
|
||||||
|
setMe(profile);
|
||||||
|
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
|
||||||
|
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
|
||||||
|
setServerId(chosen?.id ?? null);
|
||||||
|
} catch {
|
||||||
|
setMe(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const fromLink = await consumeLinkToken();
|
||||||
|
await loadMe(fromLink);
|
||||||
|
})();
|
||||||
|
}, [consumeLinkToken, loadMe]);
|
||||||
|
|
||||||
|
const applyServerState = useCallback((payload: ServerStateResponse) => {
|
||||||
|
setState(payload.state);
|
||||||
|
setPosition(payload.state.position);
|
||||||
|
setVoiceChannels(payload.voiceChannels);
|
||||||
|
setYourVoiceChannel(payload.yourVoiceChannel);
|
||||||
|
setCanControl(payload.canControl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
applyServerState(await api.state(id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyServerState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverId) return;
|
||||||
|
localStorage.setItem(SERVER_KEY, serverId);
|
||||||
|
void refresh(serverId);
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const payload = JSON.parse(event.data as string) as
|
||||||
|
| { type: "state"; state: PlayerState }
|
||||||
|
| { type: "position"; position: number };
|
||||||
|
if (payload.type === "state") {
|
||||||
|
setState(payload.state);
|
||||||
|
setPosition(payload.state.position);
|
||||||
|
} else {
|
||||||
|
setPosition(payload.position);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, [serverId, refresh]);
|
||||||
|
|
||||||
|
const runAction = useCallback(
|
||||||
|
async (action: string, payload: Record<string, unknown> = {}) => {
|
||||||
|
if (!serverId) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.action(serverId, action, payload);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Действие не выполнено");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <div className="login-wrap">Загрузка…</div>;
|
||||||
|
if (!me) return <Login onSuccess={() => void loadMe()} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="dot" />
|
||||||
|
Stoat Music
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{me.servers.length > 0 && (
|
||||||
|
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
|
||||||
|
{me.servers.map((server) => (
|
||||||
|
<option key={server.id} value={server.id}>
|
||||||
|
{server.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="spacer" />
|
||||||
|
<span className="who">{me.user.username}</span>
|
||||||
|
<button
|
||||||
|
className="ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
await api.logout();
|
||||||
|
setMe(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
{!serverId || !state ? (
|
||||||
|
<div className="card empty">
|
||||||
|
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="layout">
|
||||||
|
<div>
|
||||||
|
<SearchPanel
|
||||||
|
serverId={serverId}
|
||||||
|
canControl={canControl}
|
||||||
|
localLibrary={me.features.localLibrary}
|
||||||
|
onError={setError}
|
||||||
|
/>
|
||||||
|
<QueueList
|
||||||
|
tracks={state.queue}
|
||||||
|
canControl={canControl}
|
||||||
|
onRemove={(track) => {
|
||||||
|
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
|
||||||
|
}}
|
||||||
|
onMove={(track, index) => {
|
||||||
|
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
|
||||||
|
}}
|
||||||
|
onClear={() => void runAction("clear")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NowPlaying
|
||||||
|
state={state}
|
||||||
|
position={position}
|
||||||
|
canControl={canControl}
|
||||||
|
voiceChannels={voiceChannels}
|
||||||
|
yourVoiceChannel={yourVoiceChannel}
|
||||||
|
onAction={(action, payload) => void runAction(action, payload)}
|
||||||
|
onSeek={(seconds) => void runAction("seek", { position: seconds })}
|
||||||
|
onJoin={(channelId) => {
|
||||||
|
void api
|
||||||
|
.join(serverId, channelId)
|
||||||
|
.then(() => refresh(serverId))
|
||||||
|
.catch((err: Error) => setError(err.message));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{state.history.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>История</h2>
|
||||||
|
<ul className="track-list">
|
||||||
|
{state.history.map((track, index) => (
|
||||||
|
<li className="track" key={`${track.id}-${index}`}>
|
||||||
|
<span className="idx">{index + 1}</span>
|
||||||
|
<div className="info">
|
||||||
|
<div className="title">{track.title}</div>
|
||||||
|
<div className="sub">{track.author ?? ""}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Me, ServerStateResponse, Track } from "./types";
|
||||||
|
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: init.body ? { "content-type": "application/json" } : undefined,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const body = text ? JSON.parse(text) : null;
|
||||||
|
if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`);
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
user?: { id: string; username: string };
|
||||||
|
mfaRequired?: boolean;
|
||||||
|
ticket?: string;
|
||||||
|
methods?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () => request<Me>("/api/me"),
|
||||||
|
|
||||||
|
login: (payload: {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
mfaTicket?: string;
|
||||||
|
totpCode?: string;
|
||||||
|
recoveryCode?: string;
|
||||||
|
}) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
|
loginWithLink: (token: string) =>
|
||||||
|
request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
|
||||||
|
|
||||||
|
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
|
||||||
|
|
||||||
|
search: (serverId: string, query: string) =>
|
||||||
|
request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`),
|
||||||
|
|
||||||
|
play: (
|
||||||
|
serverId: string,
|
||||||
|
payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null },
|
||||||
|
) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
|
||||||
|
action: (serverId: string, action: string, payload: Record<string, unknown> = {}) =>
|
||||||
|
request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
join: (serverId: string, voiceChannelId: string | null) =>
|
||||||
|
request<{ ok: true }>(`/api/servers/${serverId}/join`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ voiceChannelId }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeTrack: (serverId: string, trackId: string) =>
|
||||||
|
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
moveTrack: (serverId: string, trackId: string, index: number) =>
|
||||||
|
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ index }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
|
||||||
|
const total = Math.floor(seconds);
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const secs = total % 60;
|
||||||
|
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||||
|
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
export function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [totp, setTotp] = useState("");
|
||||||
|
const [ticket, setTicket] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = ticket
|
||||||
|
? await api.login({ mfaTicket: ticket, totpCode: totp })
|
||||||
|
: await api.login({ email, password });
|
||||||
|
if (result.mfaRequired && result.ticket) {
|
||||||
|
setTicket(result.ticket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Не удалось войти");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-wrap">
|
||||||
|
<form className="card login" onSubmit={submit}>
|
||||||
|
<h1>Stoat Music</h1>
|
||||||
|
<p className="sub">Войдите учётной записью вашего Stoat-инстанса.</p>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
{ticket ? (
|
||||||
|
<>
|
||||||
|
<label htmlFor="totp">Код двухфакторной аутентификации</label>
|
||||||
|
<input
|
||||||
|
id="totp"
|
||||||
|
value={totp}
|
||||||
|
onChange={(event) => setTotp(event.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label htmlFor="email">E-mail</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<label htmlFor="password">Пароль</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="primary" type="submit" disabled={busy}>
|
||||||
|
{busy ? "Проверяю…" : "Войти"}
|
||||||
|
</button>
|
||||||
|
<p className="hint">
|
||||||
|
Пароль уходит напрямую в API вашего инстанса, бот его не сохраняет. Быстрее — команда{" "}
|
||||||
|
<code>!panel</code> в чате: она выдаёт персональную ссылку без пароля.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useState, type MouseEvent } from "react";
|
||||||
|
import { formatDuration } from "../api";
|
||||||
|
import type { LoopMode, PlayerState, VoiceChannel } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
state: PlayerState;
|
||||||
|
position: number;
|
||||||
|
canControl: boolean;
|
||||||
|
voiceChannels: VoiceChannel[];
|
||||||
|
yourVoiceChannel: VoiceChannel | null;
|
||||||
|
onAction(action: string, payload?: Record<string, unknown>): void;
|
||||||
|
onSeek(seconds: number): void;
|
||||||
|
onJoin(channelId: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<PlayerState["status"], string> = {
|
||||||
|
idle: "ожидание",
|
||||||
|
connecting: "подключение",
|
||||||
|
buffering: "буферизация",
|
||||||
|
playing: "играет",
|
||||||
|
paused: "пауза",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOOP_LABEL: Record<LoopMode, string> = {
|
||||||
|
off: "🔁 выкл",
|
||||||
|
track: "🔂 трек",
|
||||||
|
queue: "🔁 очередь",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NowPlaying({
|
||||||
|
state,
|
||||||
|
position,
|
||||||
|
canControl,
|
||||||
|
voiceChannels,
|
||||||
|
yourVoiceChannel,
|
||||||
|
onAction,
|
||||||
|
onSeek,
|
||||||
|
onJoin,
|
||||||
|
}: Props) {
|
||||||
|
const [volume, setVolume] = useState(state.volume);
|
||||||
|
const [channelId, setChannelId] = useState<string>(
|
||||||
|
state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => setVolume(state.volume), [state.volume]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.voiceChannelId) setChannelId(state.voiceChannelId);
|
||||||
|
}, [state.voiceChannelId]);
|
||||||
|
|
||||||
|
const track = state.current;
|
||||||
|
const duration = track?.duration ?? 0;
|
||||||
|
const ratio = duration > 0 ? Math.min(1, position / duration) : 0;
|
||||||
|
const isPlaying = state.status === "playing" || state.status === "buffering";
|
||||||
|
|
||||||
|
function seekFromClick(event: MouseEvent<HTMLDivElement>) {
|
||||||
|
if (!track || duration <= 0 || !canControl) return;
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const fraction = (event.clientX - rect.left) / rect.width;
|
||||||
|
onSeek(Math.max(0, Math.min(duration - 1, fraction * duration)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>
|
||||||
|
Сейчас играет{" "}
|
||||||
|
<span className={`status-pill ${state.status === "playing" ? "playing" : ""}`}>
|
||||||
|
{STATUS_LABEL[state.status]}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="now">
|
||||||
|
{track?.thumbnail ? (
|
||||||
|
<img className="cover" src={track.thumbnail} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="cover placeholder">🎵</div>
|
||||||
|
)}
|
||||||
|
<div className="now-meta">
|
||||||
|
<div className="now-title">
|
||||||
|
{track ? (
|
||||||
|
/^https?:/.test(track.url) ? (
|
||||||
|
<a href={track.url} target="_blank" rel="noreferrer noopener">
|
||||||
|
{track.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
track.title
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
"Тишина"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="now-sub">
|
||||||
|
{track
|
||||||
|
? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ")
|
||||||
|
: "Очередь пуста — найдите что-нибудь слева"}
|
||||||
|
</div>
|
||||||
|
<div className="progress">
|
||||||
|
<div className="bar" onClick={seekFromClick}>
|
||||||
|
<span style={{ width: `${ratio * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="times">
|
||||||
|
<span>{track ? formatDuration(position) : "0:00"}</span>
|
||||||
|
<span>{track?.isLive ? "LIVE" : formatDuration(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls">
|
||||||
|
<button
|
||||||
|
className="icon big primary"
|
||||||
|
onClick={() => onAction("toggle")}
|
||||||
|
disabled={!canControl || !track}
|
||||||
|
title={isPlaying ? "Пауза" : "Играть"}
|
||||||
|
>
|
||||||
|
{isPlaying ? "⏸" : "▶"}
|
||||||
|
</button>
|
||||||
|
<button className="icon" onClick={() => onAction("skip")} disabled={!canControl} title="Следующий">
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
<button className="icon" onClick={() => onAction("stop")} disabled={!canControl} title="Стоп">
|
||||||
|
⏹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon"
|
||||||
|
onClick={() => onAction("shuffle")}
|
||||||
|
disabled={!canControl || state.queue.length < 2}
|
||||||
|
title="Перемешать"
|
||||||
|
>
|
||||||
|
🔀
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={state.loop === "off" ? "" : "active"}
|
||||||
|
onClick={() => onAction("loop", { mode: nextLoop })}
|
||||||
|
disabled={!canControl}
|
||||||
|
title="Режим повтора"
|
||||||
|
>
|
||||||
|
{LOOP_LABEL[state.loop]}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="volume">
|
||||||
|
<span title="Громкость">🔊</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
value={volume}
|
||||||
|
disabled={!canControl}
|
||||||
|
onChange={(event) => setVolume(Number(event.target.value))}
|
||||||
|
onMouseUp={() => onAction("volume", { volume })}
|
||||||
|
onTouchEnd={() => onAction("volume", { volume })}
|
||||||
|
/>
|
||||||
|
<span className="badge">{volume}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="voice-row">
|
||||||
|
<select value={channelId} onChange={(event) => setChannelId(event.target.value)} disabled={!canControl}>
|
||||||
|
{voiceChannels.length === 0 && <option value="">Нет голосовых каналов</option>}
|
||||||
|
{voiceChannels.map((channel) => (
|
||||||
|
<option key={channel.id} value={channel.id}>
|
||||||
|
{channel.name}
|
||||||
|
{yourVoiceChannel?.id === channel.id ? " (вы здесь)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => onJoin(channelId || null)} disabled={!canControl || !channelId}>
|
||||||
|
{state.voiceChannelId === channelId ? "Переподключить" : "Зайти"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onAction("leave")} disabled={!canControl || !state.voiceChannelId}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { formatDuration } from "../api";
|
||||||
|
import type { Track } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tracks: Track[];
|
||||||
|
canControl: boolean;
|
||||||
|
onRemove(track: Track): void;
|
||||||
|
onMove(track: Track, index: number): void;
|
||||||
|
onClear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) {
|
||||||
|
const total = tracks.reduce((acc, track) => acc + track.duration, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>
|
||||||
|
Очередь · {tracks.length}
|
||||||
|
{total > 0 ? ` · ${formatDuration(total)}` : ""}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{tracks.length === 0 ? (
|
||||||
|
<div className="empty">Очередь пуста</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="track-list">
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<li className="track" key={track.id}>
|
||||||
|
<span className="idx">{index + 1}</span>
|
||||||
|
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||||
|
<div className="info">
|
||||||
|
<div className="title">{track.title}</div>
|
||||||
|
<div className="sub">
|
||||||
|
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration), track.requestedBy.username]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
onClick={() => onMove(track, 0)}
|
||||||
|
disabled={!canControl || index === 0}
|
||||||
|
title="Наверх очереди"
|
||||||
|
>
|
||||||
|
⤒
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onMove(track, Math.max(0, index - 1))}
|
||||||
|
disabled={!canControl || index === 0}
|
||||||
|
title="Выше"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onMove(track, index + 1)}
|
||||||
|
disabled={!canControl || index === tracks.length - 1}
|
||||||
|
title="Ниже"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onRemove(track)} disabled={!canControl} title="Убрать">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="row" style={{ marginTop: 12 }}>
|
||||||
|
<button className="ghost" onClick={onClear} disabled={!canControl}>
|
||||||
|
Очистить очередь
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { api, formatDuration } from "../api";
|
||||||
|
import type { Track } from "../types";
|
||||||
|
|
||||||
|
const SOURCE_BADGE: Record<Track["source"], string> = {
|
||||||
|
youtube: "YouTube",
|
||||||
|
soundcloud: "SoundCloud",
|
||||||
|
direct: "Ссылка",
|
||||||
|
local: "Медиатека",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId: string;
|
||||||
|
canControl: boolean;
|
||||||
|
localLibrary: boolean;
|
||||||
|
onError(message: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<Track[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [lastAdded, setLastAdded] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const value = query.trim();
|
||||||
|
if (!value) return;
|
||||||
|
setBusy(true);
|
||||||
|
onError(null);
|
||||||
|
try {
|
||||||
|
if (/^https?:\/\//i.test(value)) {
|
||||||
|
await api.play(serverId, { query: value });
|
||||||
|
setLastAdded(value);
|
||||||
|
setResults([]);
|
||||||
|
setQuery("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { tracks } = await api.search(serverId, value);
|
||||||
|
setResults(tracks);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof Error ? err.message : "Поиск не удался");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueue(track: Track, mode: "append" | "next" | "now") {
|
||||||
|
onError(null);
|
||||||
|
try {
|
||||||
|
await api.play(serverId, { trackIds: [track.id], mode });
|
||||||
|
setLastAdded(track.title);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof Error ? err.message : "Не удалось добавить трек");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Поиск</h2>
|
||||||
|
<form className="search-form" onSubmit={submit}>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Название трека или ссылка…"
|
||||||
|
disabled={!canControl}
|
||||||
|
/>
|
||||||
|
<button className="primary" type="submit" disabled={busy || !canControl}>
|
||||||
|
{busy ? "…" : "Найти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div className="empty">
|
||||||
|
{lastAdded ? `Добавлено: ${lastAdded}` : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="track-list">
|
||||||
|
{results.map((track, index) => (
|
||||||
|
<li className="track" key={track.id}>
|
||||||
|
<span className="idx">{index + 1}</span>
|
||||||
|
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||||
|
<div className="info">
|
||||||
|
<div className="title">{track.title}</div>
|
||||||
|
<div className="sub">
|
||||||
|
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration)].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="badge">{SOURCE_BADGE[track.source]}</span>
|
||||||
|
<div className="actions">
|
||||||
|
<button onClick={() => enqueue(track, "now")} disabled={!canControl} title="Играть сейчас">
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button onClick={() => enqueue(track, "next")} disabled={!canControl} title="Следующим">
|
||||||
|
⤴
|
||||||
|
</button>
|
||||||
|
<button onClick={() => enqueue(track, "append")} disabled={!canControl} title="В очередь">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="hint">
|
||||||
|
Префиксы: <code>sc:</code> — искать в SoundCloud, <code>yt:</code> — в YouTube
|
||||||
|
{localLibrary ? (
|
||||||
|
<>
|
||||||
|
, <code>local:</code> — в локальной медиатеке
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const container = document.getElementById("root");
|
||||||
|
if (!container) throw new Error("root element is missing");
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1014;
|
||||||
|
--bg-elev: #171922;
|
||||||
|
--bg-elev-2: #1e2130;
|
||||||
|
--line: #2a2e3f;
|
||||||
|
--text: #e8eaf2;
|
||||||
|
--muted: #9aa0b5;
|
||||||
|
--accent: #7b6cf6;
|
||||||
|
--accent-soft: rgba(123, 108, 246, 0.16);
|
||||||
|
--danger: #f2555a;
|
||||||
|
--ok: #3ecf8e;
|
||||||
|
--radius: 14px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: #262a3c;
|
||||||
|
border-color: #3a3f56;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: #8b7dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.icon.big {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: #cfc7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: 2px solid var(--accent-soft);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 18px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 4px 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand .dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 14px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card + .card {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover.placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 30px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-meta {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-title {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-title a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-title a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar > span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: linear-gradient(90deg, var(--accent), #a78bfa);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.times {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume input[type="range"] {
|
||||||
|
width: 110px;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.playing {
|
||||||
|
color: var(--ok);
|
||||||
|
border-color: rgba(62, 207, 142, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track .idx {
|
||||||
|
width: 22px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track .thumb {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track .info {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track .title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track .sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track .actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track .actions button {
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 26px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(242, 85, 90, 0.12);
|
||||||
|
border: 1px solid rgba(242, 85, 90, 0.4);
|
||||||
|
color: #ffb4b6;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login p.sub {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-row select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export type SourceKind = "youtube" | "soundcloud" | "direct" | "local";
|
||||||
|
export type LoopMode = "off" | "track" | "queue";
|
||||||
|
export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused";
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string | null;
|
||||||
|
duration: number;
|
||||||
|
isLive: boolean;
|
||||||
|
url: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
source: SourceKind;
|
||||||
|
requestedBy: { id: string; username: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
serverId: string;
|
||||||
|
serverName: string | null;
|
||||||
|
voiceChannelId: string | null;
|
||||||
|
voiceChannelName: string | null;
|
||||||
|
status: PlayerStatus;
|
||||||
|
current: Track | null;
|
||||||
|
position: number;
|
||||||
|
queue: Track[];
|
||||||
|
history: Track[];
|
||||||
|
volume: number;
|
||||||
|
loop: LoopMode;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Me {
|
||||||
|
user: { id: string; username: string };
|
||||||
|
servers: ServerRef[];
|
||||||
|
features: { localLibrary: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerStateResponse {
|
||||||
|
state: PlayerState;
|
||||||
|
voiceChannels: VoiceChannel[];
|
||||||
|
yourVoiceChannel: VoiceChannel | null;
|
||||||
|
canControl: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
const target = process.env.MBOT_API ?? "http://127.0.0.1:3005";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5180,
|
||||||
|
proxy: {
|
||||||
|
"/api": { target, changeOrigin: true },
|
||||||
|
"/ws": { target, ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: { outDir: "dist", emptyOutDir: true },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user