Fix voice join against LiveKit 0.13+ and tidy up command messages

revoice.js reads `room.isConnected()`, but @livekit/rtc-node turned that
into a getter, so every join threw "this.room.isConnected is not a
function" before the bot ever reached the channel. The player no longer
touches that getter: readiness comes from the connection's own join /
roomfetched events and is cleared when it reports the offline state.

Also delete the invoking chat message once a command is recognised
(DELETE_COMMAND_MESSAGES, on by default) so channels stay readable;
failures are non-fatal since it needs ManageMessages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-08 23:34:39 +03:00
co-authored by Claude Opus 5
parent 2b999bf2a0
commit d4880e757e
6 changed files with 573 additions and 537 deletions
+61 -58
View File
@@ -1,58 +1,61 @@
# ---------------------------------------------------------------- Stoat ---- # ---------------------------------------------------------------- Stoat ----
# Публичный адрес API вашего инстанса (тот же, что в клиенте). # Публичный адрес API вашего инстанса (тот же, что в клиенте).
# Бот ходит по нему как обычный клиент; домен резолвится в хост через # Бот ходит по нему как обычный клиент; домен резолвится в хост через
# extra_hosts в compose.yml, поэтому TLS валидный и NAT loopback не нужен. # extra_hosts в compose.yml, поэтому TLS валидный и NAT loopback не нужен.
STOAT_API_URL=https://chat.example.com/api STOAT_API_URL=https://chat.example.com/api
# Токен бота: Settings → My Bots → создать бота → скопировать токен. # Токен бота: Settings → My Bots → создать бота → скопировать токен.
STOAT_BOT_TOKEN= STOAT_BOT_TOKEN=
# Префикс команд в чате. # Префикс команд в чате.
COMMAND_PREFIX=! COMMAND_PREFIX=!
# ------------------------------------------------------------- Веб-панель --- # Удалять сообщение с командой после её распознавания (нужно право ManageMessages).
PORT=3005 DELETE_COMMAND_MESSAGES=true
HOST=0.0.0.0
# ------------------------------------------------------------- Веб-панель ---
# Адрес, по которому панель открывается в браузере (без слэша в конце). PORT=3005
PUBLIC_URL=https://music.example.com HOST=0.0.0.0
# Секрет для подписи сессионных cookie. Сгенерируйте: openssl rand -hex 32 # Адрес, по которому панель открывается в браузере (без слэша в конце).
JWT_SECRET= PUBLIC_URL=https://music.example.com
# Срок жизни сессии панели, часов. # Секрет для подписи сессионных cookie. Сгенерируйте: openssl rand -hex 32
SESSION_TTL_HOURS=168 JWT_SECRET=
# ---------------------------------------------------------------- Аудио ---- # Срок жизни сессии панели, часов.
# Путь к yt-dlp. В docker-образе он уже установлен. SESSION_TTL_HOURS=168
YTDLP_PATH=yt-dlp
# ---------------------------------------------------------------- Аудио ----
# Необязательно: cookies.txt аккаунта YouTube — снимает возрастные ограничения, # Путь к yt-dlp. В docker-образе он уже установлен.
# «Sign in to confirm you're not a bot» и открывает приватные/платные видео. YTDLP_PATH=yt-dlp
# Файл должен быть доступен на ЗАПИСЬ: yt-dlp обновляет в нём ротируемые куки.
# Подробности — в README, раздел «Учётка YouTube (cookies)». # Необязательно: cookies.txt аккаунта YouTube — снимает возрастные ограничения,
# YTDLP_COOKIES=/data/cookies.txt # «Sign in to confirm you're not a bot» и открывает приватные/платные видео.
# Файл должен быть доступен на ЗАПИСЬ: yt-dlp обновляет в нём ротируемые куки.
# Необязательно: дополнительные --extractor-args, через ";". # Подробности — в README, раздел «Учётка YouTube (cookies)».
# Помогает, когда YouTube не отдаёт форматы серверному IP: # YTDLP_COOKIES=/data/cookies.txt
# YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari
# Необязательно: дополнительные --extractor-args, через ";".
# Необязательно: каталог с локальной медиатекой (смонтируйте том). # Помогает, когда YouTube не отдаёт форматы серверному IP:
# LOCAL_MEDIA_DIR=/media/music # YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari
DEFAULT_VOLUME=60 # Необязательно: каталог с локальной медиатекой (смонтируйте том).
MAX_QUEUE_SIZE=500 # LOCAL_MEDIA_DIR=/media/music
SEARCH_RESULT_LIMIT=10
DEFAULT_VOLUME=60
# Через сколько секунд простоя бот выходит из голосового канала (0 — никогда). MAX_QUEUE_SIZE=500
IDLE_TIMEOUT_SECONDS=300 SEARCH_RESULT_LIMIT=10
# --------------------------------------------------------------- Доступ ---- # Через сколько секунд простоя бот выходит из голосового канала (0 — никогда).
# false — управлять может любой участник сервера. IDLE_TIMEOUT_SECONDS=300
# true — только владелец, ManageServer или роль DJ_ROLE_NAME.
REQUIRE_DJ_ROLE=false # --------------------------------------------------------------- Доступ ----
DJ_ROLE_NAME=DJ # false — управлять может любой участник сервера.
# true — только владелец, ManageServer или роль DJ_ROLE_NAME.
# ----------------------------------------------------------------- Прочее --- REQUIRE_DJ_ROLE=false
LOG_LEVEL=info DJ_ROLE_NAME=DJ
NODE_ENV=production
# ----------------------------------------------------------------- Прочее ---
LOG_LEVEL=info
NODE_ENV=production
+3
View File
@@ -159,6 +159,9 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
Все параметры — в [.env.example](.env.example). Что стоит знать: Все параметры — в [.env.example](.env.example). Что стоит знать:
- `DELETE_COMMAND_MESSAGES=true` (по умолчанию) — бот удаляет сообщение с командой, чтобы не
засорять канал. Нужно право `ManageMessages`; без него команда всё равно отработает,
а неудачное удаление уйдёт в лог на уровне `debug`.
- `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer` - `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer`
и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера. и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера.
- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса. - `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса.
+7
View File
@@ -66,6 +66,13 @@ async function handleMessage(manager: MusicManager, message: Message): Promise<v
const command = findCommand(rawName); const command = findCommand(rawName);
if (!command) return; if (!command) return;
if (config.DELETE_COMMAND_MESSAGES) {
// Fire-and-forget: a missing ManageMessages permission must not block playback.
void message.delete().catch((err) => {
log.debug({ err, command: command.name }, "could not delete command message");
});
}
const ctx: CommandContext = { const ctx: CommandContext = {
manager, manager,
serverId, serverId,
+5
View File
@@ -27,6 +27,11 @@ const schema = z.object({
STOAT_API_URL: z.string().url(), STOAT_API_URL: z.string().url(),
STOAT_BOT_TOKEN: z.string().min(1), STOAT_BOT_TOKEN: z.string().min(1),
COMMAND_PREFIX: z.string().min(1).default("!"), 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), PORT: z.coerce.number().int().positive().default(3005),
HOST: z.string().default("0.0.0.0"), HOST: z.string().default("0.0.0.0"),
+490 -478
View File
@@ -1,478 +1,490 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { config } from "../config.js"; import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { openPlayback, type PlaybackInput } from "../sources/index.js"; import { openPlayback, type PlaybackInput } from "../sources/index.js";
import { import {
UserFacingError, UserFacingError,
type LoopMode, type LoopMode,
type PlayerSnapshot, type PlayerSnapshot,
type PlayerStatus, type PlayerStatus,
type Track, type Track,
} from "../types.js"; } from "../types.js";
import { import {
MediaPlayer, MediaPlayer,
parseFfmpegDuration, parseFfmpegDuration,
type MediaPlayerLike, VOICE_STATE_OFFLINE,
type RevoiceLike, type MediaPlayerLike,
type VoiceConnectionLike, type RevoiceLike,
} from "./revoice.js"; type VoiceConnectionLike,
} from "./revoice.js";
const HISTORY_LIMIT = 50;
const JOIN_TIMEOUT_MS = 20_000; const HISTORY_LIMIT = 50;
const JOIN_TIMEOUT_MS = 20_000;
export interface PositionUpdate {
serverId: string; export interface PositionUpdate {
position: number; serverId: string;
duration: number; position: number;
status: PlayerStatus; duration: number;
} status: PlayerStatus;
}
export interface Notice {
serverId: string; export interface Notice {
textChannelId: string | null; serverId: string;
text: string; textChannelId: string | null;
} text: string;
}
export interface GuildPlayerEvents {
update: [PlayerSnapshot]; export interface GuildPlayerEvents {
position: [PositionUpdate]; update: [PlayerSnapshot];
notice: [Notice]; position: [PositionUpdate];
destroyed: [{ serverId: string }]; notice: [Notice];
} destroyed: [{ serverId: string }];
}
export interface GuildPlayerOptions {
serverId: string; export interface GuildPlayerOptions {
serverName: string | null; serverId: string;
revoice: RevoiceLike; 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 * Owns everything about music playback for one Stoat server: the voice
* the web panel both drive playback exclusively through this class, so the two * connection, the queue and the ffmpeg-backed media player. Chat commands and
* can never drift apart. * 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; export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
serverName: string | null; readonly serverId: string;
textChannelId: string | null = null; serverName: string | null;
voiceChannelId: string | null = null; textChannelId: string | null = null;
voiceChannelName: string | null = null; voiceChannelId: string | null = null;
voiceChannelName: string | null = null;
queue: Track[] = [];
history: Track[] = []; queue: Track[] = [];
current: Track | null = null; history: Track[] = [];
volume = config.DEFAULT_VOLUME; current: Track | null = null;
loop: LoopMode = "off"; volume = config.DEFAULT_VOLUME;
shuffleUsed = false; loop: LoopMode = "off";
shuffleUsed = false;
private status: PlayerStatus = "idle";
private readonly revoice: RevoiceLike; private status: PlayerStatus = "idle";
private connection: VoiceConnectionLike | null = null; private readonly revoice: RevoiceLike;
private media: MediaPlayerLike | null = null; private connection: VoiceConnectionLike | null = null;
private currentInput: PlaybackInput | null = null; /** Tracked from connection events: revoice's own `connected` getter is broken. */
private seekOffset = 0; private voiceReady = false;
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */ private media: MediaPlayerLike | null = null;
private expectingStop = false; private currentInput: PlaybackInput | null = null;
private idleTimer: NodeJS.Timeout | null = null; private seekOffset = 0;
private ticker: NodeJS.Timeout | null = null; /** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
private readonly log; private expectingStop = false;
private idleTimer: NodeJS.Timeout | null = null;
constructor(options: GuildPlayerOptions) { private ticker: NodeJS.Timeout | null = null;
super(); private readonly log;
this.serverId = options.serverId;
this.serverName = options.serverName; constructor(options: GuildPlayerOptions) {
this.revoice = options.revoice; super();
this.log = logger.child({ mod: "player", server: options.serverId }); this.serverId = options.serverId;
} this.serverName = options.serverName;
this.revoice = options.revoice;
// ---------------------------------------------------------------- state --- this.log = logger.child({ mod: "player", server: options.serverId });
}
get position(): number {
if (!this.media) return 0; // ---------------------------------------------------------------- state ---
return this.seekOffset + this.media.seconds;
} get position(): number {
if (!this.media) return 0;
snapshot(): PlayerSnapshot { return this.seekOffset + this.media.seconds;
return { }
serverId: this.serverId,
serverName: this.serverName, snapshot(): PlayerSnapshot {
voiceChannelId: this.voiceChannelId, return {
voiceChannelName: this.voiceChannelName, serverId: this.serverId,
textChannelId: this.textChannelId, serverName: this.serverName,
status: this.status, voiceChannelId: this.voiceChannelId,
current: this.current, voiceChannelName: this.voiceChannelName,
position: Math.round(this.position * 10) / 10, textChannelId: this.textChannelId,
queue: this.queue, status: this.status,
history: this.history.slice(0, 10), current: this.current,
volume: this.volume, position: Math.round(this.position * 10) / 10,
loop: this.loop, queue: this.queue,
shuffleUsed: this.shuffleUsed, history: this.history.slice(0, 10),
updatedAt: Date.now(), 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(); private setStatus(status: PlayerStatus): void {
} if (this.status === status) return;
this.status = status;
publish(): void { this.publish();
this.emit("update", this.snapshot()); }
}
publish(): void {
private notify(text: string): void { this.emit("update", this.snapshot());
this.emit("notice", { serverId: this.serverId, textChannelId: this.textChannelId, text }); }
}
private notify(text: string): void {
// ------------------------------------------------------------ connection --- this.emit("notice", { serverId: this.serverId, textChannelId: this.textChannelId, text });
}
isConnected(): boolean {
return Boolean(this.connection?.connected); // ------------------------------------------------------------ connection ---
}
isConnected(): boolean {
async connect(channelId: string, channelName: string | null): Promise<void> { return this.voiceReady;
if (this.connection?.connected && this.voiceChannelId === channelId) { }
this.voiceChannelName = channelName ?? this.voiceChannelName;
return; async connect(channelId: string, channelName: string | null): Promise<void> {
} if (this.voiceReady && this.voiceChannelId === channelId) {
if (this.connection) await this.leaveVoice(); this.voiceChannelName = channelName ?? this.voiceChannelName;
return;
this.setStatus("connecting"); }
this.log.info({ channelId }, "joining voice channel"); if (this.connection) await this.leaveVoice();
const connection = await this.revoice.join(channelId); this.setStatus("connecting");
if (!connection.connected) { this.log.info({ channelId }, "joining voice channel");
await new Promise<void>((resolve, reject) => {
const timer = setTimeout( const connection = await this.revoice.join(channelId);
() => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")), // The room connects asynchronously inside revoice's constructor, so we wait
JOIN_TIMEOUT_MS, // for it to report readiness rather than polling a state getter.
); await new Promise<void>((resolve, reject) => {
connection.on("join", () => { const timer = setTimeout(
clearTimeout(timer); () => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")),
resolve(); JOIN_TIMEOUT_MS,
}); );
}); const done = () => {
} clearTimeout(timer);
resolve();
this.connection = connection; };
this.voiceChannelId = channelId; connection.once("join", done);
this.voiceChannelName = channelName; connection.once("roomfetched", done);
});
connection.on("userleave", () => this.checkEmptyChannel());
connection.on("userLeave", () => this.checkEmptyChannel()); this.connection = connection;
connection.on("userJoin", () => this.clearIdleTimer()); this.voiceReady = true;
this.voiceChannelId = channelId;
const media = new MediaPlayer(true); this.voiceChannelName = channelName;
media.on("startplay", () => {
this.setStatus(media.paused ? "paused" : "playing"); connection.on("state", (state) => {
this.startTicker(); if (state !== VOICE_STATE_OFFLINE) return;
}); this.voiceReady = false;
media.on("buffer", () => this.setStatus("buffering")); this.log.warn({ channelId }, "voice connection went offline");
media.on("pause", () => this.setStatus("paused")); });
media.on("unpause", () => this.setStatus("playing")); connection.on("userleave", () => this.checkEmptyChannel());
media.on("finish", () => { connection.on("userLeave", () => this.checkEmptyChannel());
void this.handleFinish(); connection.on("userJoin", () => this.clearIdleTimer());
});
this.media = media; const media = new MediaPlayer(true);
await connection.play(media); media.on("startplay", () => {
this.setStatus(media.paused ? "paused" : "playing");
this.setStatus("idle"); this.startTicker();
this.log.info({ channelId }, "voice connection established"); });
} media.on("buffer", () => this.setStatus("buffering"));
media.on("pause", () => this.setStatus("paused"));
async leaveVoice(): Promise<void> { media.on("unpause", () => this.setStatus("playing"));
this.clearIdleTimer(); media.on("finish", () => {
this.stopTicker(); void this.handleFinish();
this.teardownPlayback(); });
this.current = null; this.media = media;
await connection.play(media);
const connection = this.connection;
this.connection = null; this.setStatus("idle");
this.media?.removeAllListeners(); this.log.info({ channelId }, "voice connection established");
this.media = null; }
this.voiceChannelId = null;
this.voiceChannelName = null; async leaveVoice(): Promise<void> {
this.clearIdleTimer();
if (connection) { this.stopTicker();
try { this.teardownPlayback();
await connection.destroy(); this.current = null;
} catch (err) {
this.log.warn({ err }, "failed to leave voice channel cleanly"); const connection = this.connection;
} this.connection = null;
connection.removeAllListeners(); this.voiceReady = false;
} this.media?.removeAllListeners();
this.setStatus("idle"); this.media = null;
this.publish(); this.voiceChannelId = null;
} this.voiceChannelName = null;
private checkEmptyChannel(): void { if (connection) {
if (!this.connection) return; try {
if (this.connection.getUsers().length > 0) return; await connection.destroy();
this.startIdleTimer("В канале никого не осталось"); } catch (err) {
} this.log.warn({ err }, "failed to leave voice channel cleanly");
}
// -------------------------------------------------------------- playback --- connection.removeAllListeners();
}
private assertReady(): MediaPlayerLike { this.setStatus("idle");
if (!this.media || !this.connection?.connected) { this.publish();
throw new UserFacingError("Бот не подключён к голосовому каналу"); }
}
return this.media; private checkEmptyChannel(): void {
} if (!this.connection) return;
if (this.connection.getUsers().length > 0) return;
enqueue(tracks: Track[], position?: number): void { this.startIdleTimer("В канале никого не осталось");
if (this.queue.length + tracks.length > config.MAX_QUEUE_SIZE) { }
throw new UserFacingError(`Очередь ограничена ${config.MAX_QUEUE_SIZE} треками`);
} // -------------------------------------------------------------- playback ---
if (position === undefined) this.queue.push(...tracks);
else this.queue.splice(Math.max(0, position), 0, ...tracks); private assertReady(): MediaPlayerLike {
this.publish(); if (!this.media || !this.voiceReady) {
} throw new UserFacingError("Бот не подключён к голосовому каналу");
}
/** Starts playback if nothing is currently playing. */ return this.media;
async ensurePlaying(): Promise<void> { }
if (this.current || this.status === "buffering" || this.status === "connecting") return;
await this.advance(false); enqueue(tracks: Track[], position?: number): void {
} if (this.queue.length + tracks.length > config.MAX_QUEUE_SIZE) {
throw new UserFacingError(`Очередь ограничена ${config.MAX_QUEUE_SIZE} треками`);
private async startPlayback(track: Track, seekSeconds = 0): Promise<void> { }
const media = this.assertReady(); if (position === undefined) this.queue.push(...tracks);
this.clearIdleTimer(); else this.queue.splice(Math.max(0, position), 0, ...tracks);
this.teardownPlayback(); this.publish();
}
this.current = track;
this.seekOffset = seekSeconds; /** Starts playback if nothing is currently playing. */
this.setStatus("buffering"); async ensurePlaying(): Promise<void> {
this.publish(); if (this.current || this.status === "buffering" || this.status === "connecting") return;
await this.advance(false);
try { }
const input = await openPlayback(track, seekSeconds);
this.currentInput = input; private async startPlayback(track: Track, seekSeconds = 0): Promise<void> {
this.expectingStop = false; const media = this.assertReady();
await media.playStream(input.input, input.inputOptions); this.clearIdleTimer();
// stop() rebuilds the volume transformer, so volume is applied per track. this.teardownPlayback();
media.setVolume(this.volume / 100);
this.startTicker(); this.current = track;
} catch (err) { this.seekOffset = seekSeconds;
this.log.warn({ err, track: track.title }, "playback failed"); this.setStatus("buffering");
const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка"; this.publish();
this.notify(`⚠️ Не удалось воспроизвести **${track.title}** (${message}), пропускаю.`);
this.current = null; try {
await this.advance(true); const input = await openPlayback(track, seekSeconds);
} this.currentInput = input;
} this.expectingStop = false;
await media.playStream(input.input, input.inputOptions);
/** Tears down ffmpeg/yt-dlp for the current track without advancing the queue. */ // stop() rebuilds the volume transformer, so volume is applied per track.
private teardownPlayback(): void { media.setVolume(this.volume / 100);
if (this.media) { this.startTicker();
this.expectingStop = true; } catch (err) {
try { this.log.warn({ err, track: track.title }, "playback failed");
this.media.fProc?.kill("SIGKILL"); const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка";
} catch { this.notify(`⚠️ Не удалось воспроизвести **${track.title}** (${message}), пропускаю.`);
// ffmpeg may already be gone. this.current = null;
} await this.advance(true);
try { }
this.media.stop(); }
} catch (err) {
this.log.debug({ err }, "media.stop() threw"); /** Tears down ffmpeg/yt-dlp for the current track without advancing the queue. */
} private teardownPlayback(): void {
} if (this.media) {
this.currentInput?.cleanup(); this.expectingStop = true;
this.currentInput = null; try {
this.seekOffset = 0; this.media.fProc?.kill("SIGKILL");
} } catch {
// ffmpeg may already be gone.
private async handleFinish(): Promise<void> { }
if (this.expectingStop) { try {
this.expectingStop = false; this.media.stop();
return; } catch (err) {
} this.log.debug({ err }, "media.stop() threw");
await this.advance(false); }
} }
this.currentInput?.cleanup();
/** Moves to the next track. `skipLoop` ignores per-track looping (used by skip). */ this.currentInput = null;
private async advance(skipLoop: boolean): Promise<void> { this.seekOffset = 0;
const finished = this.current; }
this.current = null;
this.currentInput?.cleanup(); private async handleFinish(): Promise<void> {
this.currentInput = null; if (this.expectingStop) {
this.seekOffset = 0; this.expectingStop = false;
return;
if (finished) { }
this.history.unshift(finished); await this.advance(false);
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); /** Moves to the next track. `skipLoop` ignores per-track looping (used by skip). */
} private async advance(skipLoop: boolean): Promise<void> {
const finished = this.current;
const next = this.queue.shift(); this.current = null;
if (!next) { this.currentInput?.cleanup();
this.stopTicker(); this.currentInput = null;
this.setStatus("idle"); this.seekOffset = 0;
this.publish();
if (finished) this.notify("⏹️ Очередь закончилась."); if (finished) {
this.startIdleTimer(); this.history.unshift(finished);
return; 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);
await this.startPlayback(next); }
this.notify(`▶️ Сейчас играет: **${next.title}**`);
} const next = this.queue.shift();
if (!next) {
async skip(count = 1): Promise<Track | null> { this.stopTicker();
if (!this.current && this.queue.length === 0) throw new UserFacingError("Нечего пропускать"); this.setStatus("idle");
for (let i = 1; i < count; i += 1) this.queue.shift(); this.publish();
this.teardownPlayback(); if (finished) this.notify("⏹️ Очередь закончилась.");
await this.advance(true); this.startIdleTimer();
return this.current; return;
} }
async stop(): Promise<void> { await this.startPlayback(next);
this.queue = []; this.notify(`▶️ Сейчас играет: **${next.title}**`);
this.loop = "off"; }
this.teardownPlayback();
this.current = null; async skip(count = 1): Promise<Track | null> {
this.stopTicker(); if (!this.current && this.queue.length === 0) throw new UserFacingError("Нечего пропускать");
this.setStatus("idle"); for (let i = 1; i < count; i += 1) this.queue.shift();
this.publish(); this.teardownPlayback();
this.startIdleTimer(); await this.advance(true);
} return this.current;
}
pause(): void {
const media = this.assertReady(); async stop(): Promise<void> {
if (!this.current) throw new UserFacingError("Сейчас ничего не играет"); this.queue = [];
media.pause(); this.loop = "off";
this.setStatus("paused"); this.teardownPlayback();
this.publish(); this.current = null;
} this.stopTicker();
this.setStatus("idle");
resume(): void { this.publish();
const media = this.assertReady(); this.startIdleTimer();
if (!this.current) throw new UserFacingError("Сейчас ничего не играет"); }
media.resume();
this.setStatus("playing"); pause(): void {
this.publish(); const media = this.assertReady();
} if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
media.pause();
setVolume(volume: number): void { this.setStatus("paused");
const clamped = Math.min(200, Math.max(0, Math.round(volume))); this.publish();
this.volume = clamped; }
this.media?.setVolume(clamped / 100);
this.publish(); resume(): void {
} const media = this.assertReady();
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
setLoop(mode: LoopMode): void { media.resume();
this.loop = mode; this.setStatus("playing");
this.publish(); this.publish();
} }
shuffle(): void { setVolume(volume: number): void {
for (let i = this.queue.length - 1; i > 0; i -= 1) { const clamped = Math.min(200, Math.max(0, Math.round(volume)));
const j = Math.floor(Math.random() * (i + 1)); this.volume = clamped;
const a = this.queue[i]; this.media?.setVolume(clamped / 100);
const b = this.queue[j]; this.publish();
if (a && b) { }
this.queue[i] = b;
this.queue[j] = a; setLoop(mode: LoopMode): void {
} this.loop = mode;
} this.publish();
this.shuffleUsed = true; }
this.publish();
} shuffle(): void {
for (let i = this.queue.length - 1; i > 0; i -= 1) {
remove(trackId: string): Track { const j = Math.floor(Math.random() * (i + 1));
const index = this.queue.findIndex((track) => track.id === trackId); const a = this.queue[i];
if (index === -1) throw new UserFacingError("Трек не найден в очереди"); const b = this.queue[j];
const [removed] = this.queue.splice(index, 1); if (a && b) {
this.publish(); this.queue[i] = b;
return removed as Track; this.queue[j] = a;
} }
}
move(trackId: string, toIndex: number): void { this.shuffleUsed = true;
const from = this.queue.findIndex((track) => track.id === trackId); this.publish();
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); remove(trackId: string): Track {
if (track) this.queue.splice(target, 0, track); const index = this.queue.findIndex((track) => track.id === trackId);
this.publish(); if (index === -1) throw new UserFacingError("Трек не найден в очереди");
} const [removed] = this.queue.splice(index, 1);
this.publish();
clearQueue(): void { return removed as Track;
this.queue = []; }
this.publish();
} move(trackId: string, toIndex: number): void {
const from = this.queue.findIndex((track) => track.id === trackId);
async seek(seconds: number): Promise<void> { if (from === -1) throw new UserFacingError("Трек не найден в очереди");
const track = this.current; const target = Math.min(this.queue.length - 1, Math.max(0, toIndex));
if (!track) throw new UserFacingError("Сейчас ничего не играет"); const [track] = this.queue.splice(from, 1);
if (track.isLive) throw new UserFacingError("Нельзя перематывать прямой эфир"); if (track) this.queue.splice(target, 0, track);
if (track.duration > 0 && seconds >= track.duration) { this.publish();
throw new UserFacingError("Позиция за пределами трека"); }
}
this.teardownPlayback(); clearQueue(): void {
await this.startPlayback(track, Math.max(0, seconds)); this.queue = [];
} this.publish();
}
async playNow(tracks: Track[]): Promise<void> {
if (tracks.length === 0) return; async seek(seconds: number): Promise<void> {
this.queue.unshift(...tracks); const track = this.current;
this.teardownPlayback(); if (!track) throw new UserFacingError("Сейчас ничего не играет");
await this.advance(true); if (track.isLive) throw new UserFacingError("Нельзя перематывать прямой эфир");
} if (track.duration > 0 && seconds >= track.duration) {
throw new UserFacingError("Позиция за пределами трека");
// ---------------------------------------------------------- housekeeping --- }
this.teardownPlayback();
private startTicker(): void { await this.startPlayback(track, Math.max(0, seconds));
if (this.ticker) return; }
this.ticker = setInterval(() => {
if (!this.current || !this.media) return; async playNow(tracks: Track[]): Promise<void> {
// ffmpeg reports the real duration once it has probed the input, which is if (tracks.length === 0) return;
// the only way we learn how long a local file or a direct URL is. this.queue.unshift(...tracks);
if (this.current.duration === 0 && !this.current.isLive) { this.teardownPlayback();
const probed = parseFfmpegDuration(this.media.codecData?.duration); await this.advance(true);
if (probed > 0) { }
this.current.duration = Math.round(probed);
this.publish(); // ---------------------------------------------------------- housekeeping ---
}
} private startTicker(): void {
this.emit("position", { if (this.ticker) return;
serverId: this.serverId, this.ticker = setInterval(() => {
position: Math.round(this.position * 10) / 10, if (!this.current || !this.media) return;
duration: this.current.duration, // ffmpeg reports the real duration once it has probed the input, which is
status: this.status, // the only way we learn how long a local file or a direct URL is.
}); if (this.current.duration === 0 && !this.current.isLive) {
}, 1000); const probed = parseFfmpegDuration(this.media.codecData?.duration);
this.ticker.unref?.(); if (probed > 0) {
} this.current.duration = Math.round(probed);
this.publish();
private stopTicker(): void { }
if (!this.ticker) return; }
clearInterval(this.ticker); this.emit("position", {
this.ticker = null; serverId: this.serverId,
} position: Math.round(this.position * 10) / 10,
duration: this.current.duration,
private clearIdleTimer(): void { status: this.status,
if (!this.idleTimer) return; });
clearTimeout(this.idleTimer); }, 1000);
this.idleTimer = null; this.ticker.unref?.();
} }
private startIdleTimer(reason?: string): void { private stopTicker(): void {
this.clearIdleTimer(); if (!this.ticker) return;
if (config.IDLE_TIMEOUT_SECONDS <= 0 || !this.connection) return; clearInterval(this.ticker);
this.idleTimer = setTimeout(() => { this.ticker = null;
if (this.current) return; }
this.notify(`👋 ${reason ?? "Нет активности"}, выхожу из голосового канала.`);
void this.leaveVoice(); private clearIdleTimer(): void {
}, config.IDLE_TIMEOUT_SECONDS * 1000); if (!this.idleTimer) return;
this.idleTimer.unref?.(); clearTimeout(this.idleTimer);
} this.idleTimer = null;
}
async destroy(): Promise<void> {
await this.leaveVoice(); private startIdleTimer(reason?: string): void {
this.emit("destroyed", { serverId: this.serverId }); this.clearIdleTimer();
this.removeAllListeners(); 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();
}
}
+7 -1
View File
@@ -23,8 +23,10 @@ export interface MediaPlayerLike {
removeAllListeners(event?: string): this; removeAllListeners(event?: string): this;
} }
/** Voice connection state strings emitted by revoice.js (`Revoice.State`). */
export const VOICE_STATE_OFFLINE = "off";
export interface VoiceConnectionLike { export interface VoiceConnectionLike {
readonly connected: boolean;
channelId: string; channelId: string;
play(media: MediaPlayerLike): Promise<void>; play(media: MediaPlayerLike): Promise<void>;
leave(): Promise<void>; leave(): Promise<void>;
@@ -33,8 +35,12 @@ export interface VoiceConnectionLike {
on(event: "join" | "leave" | "roomfetched" | "autoleave", listener: () => void): this; on(event: "join" | "leave" | "roomfetched" | "autoleave", listener: () => void): this;
on(event: "state", listener: (state: string) => void): this; on(event: "state", listener: (state: string) => void): this;
on(event: "userJoin" | "userleave" | "userLeave", listener: (user: { id: 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; 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 { export interface RevoiceLike {
join(channelId: string, leaveIfEmpty?: boolean | number): Promise<VoiceConnectionLike>; join(channelId: string, leaveIfEmpty?: boolean | number): Promise<VoiceConnectionLike>;