Surface Stoat's voice errors and stop leaving orphan connections
Joining a channel failed silently: revoice's join() runs an async executor inside `new Promise`, so a rejected join_call never reaches reject() — the promise hangs forever and the real error escapes as an unhandled rejection. The client wrapper now latches API failures and settles the join itself, translating Stoat's error codes (AlreadyConnected, LiveKitUnavailable, UnknownNode, ...) into messages the chat can show. A failed join also used to leave the connection object alive, which kept the bot registered in the channel and made the next attempt fail with AlreadyConnected; it is now destroyed on any failure. The LiveKit node name is configurable via VOICE_NODE for instances that renamed it, and the README documents how to clear a stuck voice state from Redis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d4880e757e
commit
823a9f1565
+303
-303
@@ -1,303 +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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
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 { createRevoice, 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 = createRevoice(config.STOAT_BOT_TOKEN, config.STOAT_API_URL, config.VOICE_NODE);
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user