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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user