diff --git a/.env.example b/.env.example index 5c474e7..bea0b76 100644 --- a/.env.example +++ b/.env.example @@ -1,58 +1,61 @@ -# ---------------------------------------------------------------- Stoat ---- -# Публичный адрес API вашего инстанса (тот же, что в клиенте). -# Бот ходит по нему как обычный клиент; домен резолвится в хост через -# extra_hosts в compose.yml, поэтому TLS валидный и NAT loopback не нужен. -STOAT_API_URL=https://chat.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 аккаунта YouTube — снимает возрастные ограничения, -# «Sign in to confirm you're not a bot» и открывает приватные/платные видео. -# Файл должен быть доступен на ЗАПИСЬ: yt-dlp обновляет в нём ротируемые куки. -# Подробности — в README, раздел «Учётка YouTube (cookies)». -# YTDLP_COOKIES=/data/cookies.txt - -# Необязательно: дополнительные --extractor-args, через ";". -# Помогает, когда YouTube не отдаёт форматы серверному IP: -# YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari - -# Необязательно: каталог с локальной медиатекой (смонтируйте том). -# 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 +# ---------------------------------------------------------------- Stoat ---- +# Публичный адрес API вашего инстанса (тот же, что в клиенте). +# Бот ходит по нему как обычный клиент; домен резолвится в хост через +# extra_hosts в compose.yml, поэтому TLS валидный и NAT loopback не нужен. +STOAT_API_URL=https://chat.example.com/api + +# Токен бота: Settings → My Bots → создать бота → скопировать токен. +STOAT_BOT_TOKEN= + +# Префикс команд в чате. +COMMAND_PREFIX=! + +# Удалять сообщение с командой после её распознавания (нужно право ManageMessages). +DELETE_COMMAND_MESSAGES=true + +# ------------------------------------------------------------- Веб-панель --- +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 аккаунта YouTube — снимает возрастные ограничения, +# «Sign in to confirm you're not a bot» и открывает приватные/платные видео. +# Файл должен быть доступен на ЗАПИСЬ: yt-dlp обновляет в нём ротируемые куки. +# Подробности — в README, раздел «Учётка YouTube (cookies)». +# YTDLP_COOKIES=/data/cookies.txt + +# Необязательно: дополнительные --extractor-args, через ";". +# Помогает, когда YouTube не отдаёт форматы серверному IP: +# YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari + +# Необязательно: каталог с локальной медиатекой (смонтируйте том). +# 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 diff --git a/README.md b/README.md index 715fce2..12518c4 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,9 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f Все параметры — в [.env.example](.env.example). Что стоит знать: +- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не + засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает, + а неудачное удаление уйдёт в лог на уровне `debug`. - `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer` и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера. - `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса. diff --git a/src/bot/index.ts b/src/bot/index.ts index d3e9df6..e8935db 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -66,6 +66,13 @@ async function handleMessage(manager: MusicManager, message: Message): Promise { + log.debug({ err, command: command.name }, "could not delete command message"); + }); + } + const ctx: CommandContext = { manager, serverId, diff --git a/src/config.ts b/src/config.ts index b842053..dc55112 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,11 @@ const schema = z.object({ STOAT_API_URL: z.string().url(), STOAT_BOT_TOKEN: z.string().min(1), COMMAND_PREFIX: z.string().min(1).default("!"), + /** Remove the invoking message after a command is recognised. Needs ManageMessages. */ + DELETE_COMMAND_MESSAGES: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true"), PORT: z.coerce.number().int().positive().default(3005), HOST: z.string().default("0.0.0.0"), diff --git a/src/core/player.ts b/src/core/player.ts index 99be309..81031e9 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -1,478 +1,490 @@ -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 { - 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 { - 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((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 { - 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 { - if (this.current || this.status === "buffering" || this.status === "connecting") return; - await this.advance(false); - } - - private async startPlayback(track: Track, seekSeconds = 0): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - await this.leaveVoice(); - this.emit("destroyed", { serverId: this.serverId }); - this.removeAllListeners(); - } -} +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, + VOICE_STATE_OFFLINE, + 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 { + 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; + /** Tracked from connection events: revoice's own `connected` getter is broken. */ + private voiceReady = false; + 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 this.voiceReady; + } + + async connect(channelId: string, channelName: string | null): Promise { + if (this.voiceReady && 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); + // The room connects asynchronously inside revoice's constructor, so we wait + // for it to report readiness rather than polling a state getter. + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")), + JOIN_TIMEOUT_MS, + ); + const done = () => { + clearTimeout(timer); + resolve(); + }; + connection.once("join", done); + connection.once("roomfetched", done); + }); + + this.connection = connection; + this.voiceReady = true; + this.voiceChannelId = channelId; + this.voiceChannelName = channelName; + + connection.on("state", (state) => { + if (state !== VOICE_STATE_OFFLINE) return; + this.voiceReady = false; + this.log.warn({ channelId }, "voice connection went offline"); + }); + 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 { + this.clearIdleTimer(); + this.stopTicker(); + this.teardownPlayback(); + this.current = null; + + const connection = this.connection; + this.connection = null; + this.voiceReady = false; + 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.voiceReady) { + 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 { + if (this.current || this.status === "buffering" || this.status === "connecting") return; + await this.advance(false); + } + + private async startPlayback(track: Track, seekSeconds = 0): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.leaveVoice(); + this.emit("destroyed", { serverId: this.serverId }); + this.removeAllListeners(); + } +} diff --git a/src/core/revoice.ts b/src/core/revoice.ts index f46fbdd..a7dd18e 100644 --- a/src/core/revoice.ts +++ b/src/core/revoice.ts @@ -23,8 +23,10 @@ export interface MediaPlayerLike { removeAllListeners(event?: string): this; } +/** Voice connection state strings emitted by revoice.js (`Revoice.State`). */ +export const VOICE_STATE_OFFLINE = "off"; + export interface VoiceConnectionLike { - readonly connected: boolean; channelId: string; play(media: MediaPlayerLike): Promise; leave(): Promise; @@ -33,8 +35,12 @@ export interface VoiceConnectionLike { 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; + once(event: "join" | "leave" | "roomfetched", listener: () => void): this; removeAllListeners(event?: string): this; } +// NB: revoice.js also exposes `connection.connected` / `isConnected()`, but both +// call `room.isConnected()` — a getter, not a method, in @livekit/rtc-node 0.13+, +// so touching them throws a TypeError. GuildPlayer tracks readiness from events. export interface RevoiceLike { join(channelId: string, leaveIfEmpty?: boolean | number): Promise;