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:
Leonid Pershin
2026-09-08 23:12:43 +03:00
co-authored by Claude Opus 5
parent d9d0e9f6bf
commit a9b7ccdd16
43 changed files with 12436 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import fastifyStatic from "@fastify/static";
import websocket from "@fastify/websocket";
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import { z } from "zod";
import { signSession, verifyToken } from "../auth/tokens.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
import type { MusicManager } from "../core/manager.js";
import type { BotStoatContext } from "../bot/context.js";
import { fetchSelf, loginWithPassword, revokeSession } from "../stoat/rest.js";
import { UserFacingError, type Track } from "../types.js";
const log = logger.child({ mod: "api" });
const COOKIE_NAME = "mbot_session";
const SEARCH_CACHE_TTL_MS = 15 * 60_000;
const SEARCH_CACHE_LIMIT = 5000;
/**
* The panel never sends whole track objects back to us — it sends ids that were
* produced by a server-side search. That keeps clients from pointing playback at
* arbitrary local paths or internal URLs.
*/
const searchCache = new Map<string, { track: Track; at: number }>();
function cacheTracks(tracks: Track[]): void {
const now = Date.now();
for (const track of tracks) searchCache.set(track.id, { track, at: now });
if (searchCache.size > SEARCH_CACHE_LIMIT) {
for (const [id, entry] of searchCache) {
if (now - entry.at > SEARCH_CACHE_TTL_MS) searchCache.delete(id);
if (searchCache.size <= SEARCH_CACHE_LIMIT) break;
}
}
}
function takeTracks(ids: string[]): Track[] {
const tracks: Track[] = [];
for (const id of ids) {
const entry = searchCache.get(id);
if (!entry || Date.now() - entry.at > SEARCH_CACHE_TTL_MS) continue;
tracks.push(entry.track);
}
if (tracks.length === 0) throw new UserFacingError("Результаты поиска устарели, повторите поиск");
return tracks;
}
interface Session {
userId: string;
username: string;
}
declare module "fastify" {
interface FastifyRequest {
session?: Session;
}
}
export interface ApiServerOptions {
manager: MusicManager;
context: BotStoatContext;
}
export async function startApiServer({ manager, context }: ApiServerOptions) {
const app = Fastify({ logger: false, trustProxy: true });
await app.register(cookie);
await app.register(websocket);
const secureCookies = config.PUBLIC_URL.startsWith("https://");
function setSessionCookie(reply: FastifyReply, token: string): void {
reply.setCookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: secureCookies,
path: "/",
maxAge: config.SESSION_TTL_HOURS * 3600,
});
}
async function readSession(request: FastifyRequest): Promise<Session | null> {
const token = request.cookies[COOKIE_NAME];
if (!token) return null;
const claims = await verifyToken(token);
if (!claims || claims.typ !== "session") return null;
return { userId: claims.sub, username: claims.username };
}
async function requireSession(request: FastifyRequest, reply: FastifyReply): Promise<Session | null> {
const session = await readSession(request);
if (!session) {
await reply.code(401).send({ error: "Не авторизован" });
return null;
}
request.session = session;
return session;
}
/** Membership is re-checked on every request: roles change in Stoat, not here. */
async function requireServerAccess(
request: FastifyRequest,
reply: FastifyReply,
serverId: string,
): Promise<Session | null> {
const session = await requireSession(request, reply);
if (!session) return null;
if (!(await context.isMember(serverId, session.userId))) {
await reply.code(403).send({ error: "Нет доступа к этому серверу" });
return null;
}
return session;
}
app.setErrorHandler((error, _request, reply) => {
if (error instanceof UserFacingError) {
void reply.code(400).send({ error: error.message });
return;
}
if ((error as { validation?: unknown }).validation) {
void reply.code(400).send({ error: "Некорректный запрос" });
return;
}
log.error({ err: error }, "request failed");
void reply.code(500).send({ error: "Внутренняя ошибка" });
});
// ------------------------------------------------------------------ auth ---
const loginSchema = z.object({
email: z.string().min(1).optional(),
password: z.string().min(1).optional(),
mfaTicket: z.string().optional(),
totpCode: z.string().optional(),
recoveryCode: z.string().optional(),
});
app.post("/api/auth/login", async (request, reply) => {
const body = loginSchema.parse(request.body ?? {});
const mfa = body.mfaTicket
? { ticket: body.mfaTicket, totpCode: body.totpCode, recoveryCode: body.recoveryCode }
: undefined;
if (!mfa && (!body.email || !body.password)) {
throw new UserFacingError("Укажите e-mail и пароль");
}
const result = await loginWithPassword(body.email ?? "", body.password ?? "", mfa);
if (result.kind === "mfa") {
return reply.send({ mfaRequired: true, ticket: result.ticket, methods: result.methods });
}
// We only needed the session to prove who the user is; drop it immediately.
const profile = await fetchSelf(result.token).catch(() => null);
await revokeSession(result.token);
const username = profile?.display_name || profile?.username || "user";
const token = await signSession(result.userId, username);
setSessionCookie(reply, token);
return reply.send({ user: { id: result.userId, username } });
});
app.post("/api/auth/link", async (request, reply) => {
const body = z.object({ token: z.string().min(1) }).parse(request.body ?? {});
const claims = await verifyToken(body.token);
if (!claims || claims.typ !== "link") throw new UserFacingError("Ссылка недействительна или устарела");
const token = await signSession(claims.sub, claims.username);
setSessionCookie(reply, token);
return reply.send({ user: { id: claims.sub, username: claims.username }, serverId: claims.srv ?? null });
});
app.post("/api/auth/logout", async (_request, reply) => {
reply.clearCookie(COOKIE_NAME, { path: "/" });
return reply.send({ ok: true });
});
app.get("/api/me", async (request, reply) => {
const session = await requireSession(request, reply);
if (!session) return reply;
const servers = await context.listServersForUser(session.userId);
return reply.send({
user: { id: session.userId, username: session.username },
servers,
features: { localLibrary: Boolean(config.LOCAL_MEDIA_DIR) },
});
});
// --------------------------------------------------------------- player ---
const serverParams = z.object({ id: z.string().min(1) });
app.get("/api/servers/:id/state", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
return reply.send({
state: manager.snapshot(id),
voiceChannels: context.listVoiceChannels(id),
yourVoiceChannel: context.findUserVoiceChannel(id, session.userId),
canControl: await context.canControl(id, session.userId),
});
});
app.get("/api/servers/:id/search", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const { q } = z.object({ q: z.string().min(1) }).parse(request.query);
const tracks = await manager.search(q, { id: session.userId, username: session.username });
cacheTracks(tracks);
return reply.send({ tracks });
});
const playSchema = z.object({
query: z.string().min(1).optional(),
trackIds: z.array(z.string()).optional(),
mode: z.enum(["append", "next", "now"]).default("append"),
voiceChannelId: z.string().nullable().optional(),
});
app.post("/api/servers/:id/play", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const body = playSchema.parse(request.body ?? {});
const requester = { id: session.userId, username: session.username };
const outcome = body.trackIds?.length
? await manager.enqueueTracks(id, requester, takeTracks(body.trackIds), {
mode: body.mode,
voiceChannelId: body.voiceChannelId ?? null,
})
: await manager.play(id, requester, body.query ?? "", {
mode: body.mode,
voiceChannelId: body.voiceChannelId ?? null,
});
return reply.send({ ok: true, added: outcome.tracks.length, state: manager.snapshot(id) });
});
const actions: Record<string, (serverId: string, userId: string, body: unknown) => Promise<unknown>> = {
pause: (serverId, userId) => manager.pause(serverId, userId),
resume: (serverId, userId) => manager.resume(serverId, userId),
toggle: (serverId, userId) => manager.togglePause(serverId, userId),
skip: (serverId, userId, body) =>
manager.skip(serverId, userId, z.object({ count: z.number().int().min(1).default(1) }).parse(body ?? {}).count),
stop: (serverId, userId) => manager.stop(serverId, userId),
shuffle: (serverId, userId) => manager.shuffle(serverId, userId),
clear: (serverId, userId) => manager.clearQueue(serverId, userId),
leave: (serverId, userId) => manager.leave(serverId, userId),
volume: (serverId, userId, body) =>
manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume),
loop: (serverId, userId, body) =>
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
seek: (serverId, userId, body) =>
manager.seek(serverId, userId, z.object({ position: z.number().min(0) }).parse(body).position),
};
app.post("/api/servers/:id/actions/:action", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const { action } = z.object({ action: z.string() }).parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const handler = actions[action];
if (!handler) return reply.code(404).send({ error: "Неизвестное действие" });
const result = await handler(id, session.userId, request.body);
return reply.send({ ok: true, result: result ?? null, state: manager.snapshot(id) });
});
app.post("/api/servers/:id/join", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const body = z.object({ voiceChannelId: z.string().nullable().optional() }).parse(request.body ?? {});
await manager.assertControl(id, session.userId);
await manager.connect(id, session.userId, { voiceChannelId: body.voiceChannelId ?? null });
return reply.send({ ok: true, state: manager.snapshot(id) });
});
app.delete("/api/servers/:id/queue/:trackId", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
await manager.remove(id, session.userId, trackId);
return reply.send({ ok: true, state: manager.snapshot(id) });
});
app.post("/api/servers/:id/queue/:trackId/move", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const { index } = z.object({ index: z.number().int().min(0) }).parse(request.body);
await manager.move(id, session.userId, trackId, index);
return reply.send({ ok: true, state: manager.snapshot(id) });
});
// ----------------------------------------------------------- websockets ---
const subscribers = new Map<string, Set<{ send(data: string): void }>>();
function broadcast(serverId: string, payload: unknown): void {
const listeners = subscribers.get(serverId);
if (!listeners?.size) return;
const message = JSON.stringify(payload);
for (const socket of listeners) {
try {
socket.send(message);
} catch {
listeners.delete(socket);
}
}
}
manager.on("update", (snapshot) => broadcast(snapshot.serverId, { type: "state", state: snapshot }));
manager.on("position", (position) => broadcast(position.serverId, { type: "position", ...position }));
app.get("/ws", { websocket: true }, (socket, request) => {
void (async () => {
const session = await readSession(request);
const serverId = (request.query as { server?: string }).server;
if (!session || !serverId || !(await context.isMember(serverId, session.userId))) {
socket.close(4001, "unauthorized");
return;
}
const listeners = subscribers.get(serverId) ?? new Set();
listeners.add(socket);
subscribers.set(serverId, listeners);
socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) }));
socket.on("close", () => {
listeners.delete(socket);
if (listeners.size === 0) subscribers.delete(serverId);
});
})();
});
// --------------------------------------------------------------- static ---
const webRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)), "web/dist");
if (existsSync(webRoot)) {
await app.register(fastifyStatic, { root: webRoot });
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith("/api") || request.url.startsWith("/ws")) {
return reply.code(404).send({ error: "Not found" });
}
return reply.sendFile("index.html");
});
} else {
log.warn({ webRoot }, "web/dist not found — panel UI is not served (run npm run web:build)");
}
await app.listen({ port: config.PORT, host: config.HOST });
log.info({ port: config.PORT, url: config.PUBLIC_URL }, "panel is listening");
return app;
}
+53
View File
@@ -0,0 +1,53 @@
import { SignJWT, jwtVerify } from "jose";
import { config } from "../config.js";
const secret = new TextEncoder().encode(config.JWT_SECRET);
const ISSUER = "stoat-mbot";
export interface SessionClaims {
sub: string;
username: string;
/** "session" for the panel cookie, "link" for one-time links from chat. */
typ: "session" | "link";
/** Present on chat links: the server the link was issued for. */
srv?: string;
}
export async function signSession(userId: string, username: string): Promise<string> {
return new SignJWT({ username, typ: "session" })
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuer(ISSUER)
.setIssuedAt()
.setExpirationTime(`${config.SESSION_TTL_HOURS}h`)
.sign(secret);
}
export async function signPanelLink(
userId: string,
username: string,
serverId: string,
): Promise<string> {
return new SignJWT({ username, typ: "link", srv: serverId })
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuer(ISSUER)
.setIssuedAt()
.setExpirationTime("10m")
.sign(secret);
}
export async function verifyToken(token: string): Promise<SessionClaims | null> {
try {
const { payload } = await jwtVerify(token, secret, { issuer: ISSUER });
if (!payload.sub || (payload["typ"] !== "session" && payload["typ"] !== "link")) return null;
return {
sub: payload.sub,
username: String(payload["username"] ?? "unknown"),
typ: payload["typ"] as "session" | "link",
srv: typeof payload["srv"] === "string" ? payload["srv"] : undefined,
};
} catch {
return null;
}
}
+366
View File
@@ -0,0 +1,366 @@
import { signPanelLink } from "../auth/tokens.js";
import { config } from "../config.js";
import type { MusicManager } from "../core/manager.js";
import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js";
import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js";
export interface CommandContext {
manager: MusicManager;
serverId: string;
channelId: string;
actor: Requester;
/** Raw text after the command name. */
rest: string;
args: string[];
reply(content: string): Promise<void>;
}
export interface Command {
name: string;
aliases: string[];
usage: string;
description: string;
run(ctx: CommandContext): Promise<void>;
}
const SEARCH_TTL_MS = 5 * 60_000;
const searchSessions = new Map<string, { tracks: Track[]; expiresAt: number }>();
function rememberSearch(ctx: CommandContext, tracks: Track[]): void {
searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, {
tracks,
expiresAt: Date.now() + SEARCH_TTL_MS,
});
}
function recallSearch(ctx: CommandContext): Track[] | null {
const key = `${ctx.channelId}:${ctx.actor.id}`;
const entry = searchSessions.get(key);
if (!entry) return null;
if (entry.expiresAt < Date.now()) {
searchSessions.delete(key);
return null;
}
return entry.tracks;
}
async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise<void> {
if (!ctx.rest) throw new UserFacingError("Укажите название трека или ссылку");
const outcome = await ctx.manager.play(ctx.serverId, ctx.actor, ctx.rest, {
mode,
textChannelId: ctx.channelId,
});
if (outcome.playlist) {
await ctx.reply(
`📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`,
);
return;
}
const track = outcome.tracks[0];
if (!track) return;
if (outcome.startedNow || mode === "now") {
await ctx.reply(`▶️ Играю: ${trackLine(track)}`);
} else {
await ctx.reply(` В очередь (#${outcome.queuePosition}): ${trackLine(track)}`);
}
}
export const commands: Command[] = [
{
name: "play",
aliases: ["p", "играй"],
usage: "play <ссылка или название>",
description: "Добавить трек или плейлист в очередь",
run: (ctx) => playCommand(ctx, "append"),
},
{
name: "playnext",
aliases: ["pn", "next"],
usage: "playnext <ссылка или название>",
description: "Поставить трек следующим в очереди",
run: (ctx) => playCommand(ctx, "next"),
},
{
name: "playnow",
aliases: ["now"],
usage: "playnow <ссылка или название>",
description: "Включить трек немедленно",
run: (ctx) => playCommand(ctx, "now"),
},
{
name: "search",
aliases: ["s", "найди"],
usage: "search <запрос>",
description: "Найти треки и выбрать нужный командой pick",
async run(ctx) {
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
if (tracks.length === 0) {
await ctx.reply("Ничего не найдено.");
return;
}
rememberSearch(ctx, tracks);
const lines = tracks.map((track, index) => trackLine(track, index + 1));
await ctx.reply(
`🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``,
);
},
},
{
name: "pick",
aliases: ["выбрать"],
usage: "pick <номер>",
description: "Добавить трек из результатов поиска",
async run(ctx) {
const tracks = recallSearch(ctx);
if (!tracks) throw new UserFacingError("Сначала выполните поиск");
const index = Number.parseInt(ctx.args[0] ?? "", 10);
const track = tracks[index - 1];
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`);
const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], {
textChannelId: ctx.channelId,
});
await ctx.reply(
outcome.startedNow
? `▶️ Играю: ${trackLine(track)}`
: ` В очередь (#${outcome.queuePosition}): ${trackLine(track)}`,
);
},
},
{
name: "skip",
aliases: ["sk", "пропусти"],
usage: "skip [количество]",
description: "Пропустить текущий трек",
async run(ctx) {
const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1);
const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count);
await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста.");
},
},
{
name: "stop",
aliases: ["стоп"],
usage: "stop",
description: "Остановить воспроизведение и очистить очередь",
async run(ctx) {
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
await ctx.reply("⏹️ Остановлено, очередь очищена.");
},
},
{
name: "pause",
aliases: ["пауза"],
usage: "pause",
description: "Поставить на паузу / снять с паузы",
async run(ctx) {
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
},
},
{
name: "resume",
aliases: ["продолжи"],
usage: "resume",
description: "Продолжить воспроизведение",
async run(ctx) {
await ctx.manager.resume(ctx.serverId, ctx.actor.id);
await ctx.reply("▶️ Продолжаю.");
},
},
{
name: "queue",
aliases: ["q", "очередь"],
usage: "queue [страница]",
description: "Показать очередь",
async run(ctx) {
const snapshot = ctx.manager.snapshot(ctx.serverId);
if (!snapshot.current && snapshot.queue.length === 0) {
await ctx.reply("Очередь пуста.");
return;
}
const pageSize = 10;
const pages = Math.max(1, Math.ceil(snapshot.queue.length / pageSize));
const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1));
const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize);
const parts: string[] = [];
if (snapshot.current) {
parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`);
parts.push(progressBar(snapshot.position, snapshot.current.duration));
}
if (slice.length > 0) {
const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1));
parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`);
}
const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
parts.push(
`Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`,
);
await ctx.reply(parts.join("\n\n"));
},
},
{
name: "nowplaying",
aliases: ["np", "сейчас"],
usage: "nowplaying",
description: "Показать текущий трек",
async run(ctx) {
const snapshot = ctx.manager.snapshot(ctx.serverId);
if (!snapshot.current) {
await ctx.reply("Сейчас ничего не играет.");
return;
}
await ctx.reply(
`🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`,
);
},
},
{
name: "volume",
aliases: ["vol", "громкость"],
usage: "volume [0-200]",
description: "Показать или изменить громкость",
async run(ctx) {
if (ctx.args.length === 0) {
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
return;
}
const value = Number.parseInt(ctx.args[0] ?? "", 10);
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
},
},
{
name: "loop",
aliases: ["repeat", "повтор"],
usage: "loop [off|track|queue]",
description: "Режим повтора",
async run(ctx) {
const raw = (ctx.args[0] ?? "").toLowerCase();
const map: Record<string, LoopMode> = {
off: "off",
выкл: "off",
track: "track",
трек: "track",
one: "track",
queue: "queue",
очередь: "queue",
all: "queue",
};
const current = ctx.manager.snapshot(ctx.serverId).loop;
const nextMode =
map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off");
await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode);
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
},
},
{
name: "shuffle",
aliases: ["sh", "перемешай"],
usage: "shuffle",
description: "Перемешать очередь",
async run(ctx) {
await ctx.manager.shuffle(ctx.serverId, ctx.actor.id);
await ctx.reply("🔀 Очередь перемешана.");
},
},
{
name: "remove",
aliases: ["rm", "удали"],
usage: "remove <номер>",
description: "Убрать трек из очереди",
async run(ctx) {
const index = Number.parseInt(ctx.args[0] ?? "", 10);
const snapshot = ctx.manager.snapshot(ctx.serverId);
const track = snapshot.queue[index - 1];
if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди");
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
await ctx.reply(`🗑️ Удалено: **${track.title}**`);
},
},
{
name: "clear",
aliases: ["очисти"],
usage: "clear",
description: "Очистить очередь, не останавливая текущий трек",
async run(ctx) {
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
await ctx.reply("🧹 Очередь очищена.");
},
},
{
name: "seek",
aliases: ["перемотай"],
usage: "seek <мм:сс>",
description: "Перемотать текущий трек",
async run(ctx) {
const seconds = parseTimecode(ctx.rest);
if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`");
await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds);
await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`);
},
},
{
name: "join",
aliases: ["зайди"],
usage: "join",
description: "Позвать бота в ваш голосовой канал",
async run(ctx) {
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, {
textChannelId: ctx.channelId,
});
await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`);
},
},
{
name: "leave",
aliases: ["dc", "выйди"],
usage: "leave",
description: "Выйти из голосового канала",
async run(ctx) {
await ctx.manager.leave(ctx.serverId, ctx.actor.id);
await ctx.reply("👋 Вышел из голосового канала.");
},
},
{
name: "panel",
aliases: ["ui", "панель"],
usage: "panel",
description: "Получить ссылку на веб-панель",
async run(ctx) {
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
await ctx.reply(
`🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`,
);
},
},
{
name: "help",
aliases: ["h", "помощь"],
usage: "help",
description: "Показать список команд",
async run(ctx) {
const lines = commands.map(
(command) => `\`${config.COMMAND_PREFIX}${command.usage}\`${command.description}`,
);
await ctx.reply(
`**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`,
);
},
},
];
const lookup = new Map<string, Command>();
for (const command of commands) {
lookup.set(command.name, command);
for (const alias of command.aliases) lookup.set(alias, command);
}
export function findCommand(name: string): Command | undefined {
return lookup.get(name.toLowerCase());
}
+120
View File
@@ -0,0 +1,120 @@
import type { Client } from "stoat.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
import type { ServerRef, StoatContext, VoiceChannelRef } from "../core/manager.js";
import { fetchMember } from "../stoat/rest.js";
const log = logger.child({ mod: "bot-context" });
const MEMBERSHIP_TTL_MS = 30_000;
interface MembershipInfo {
isMember: boolean;
roles: string[];
at: number;
}
/** Implements the core's view of Stoat on top of the live bot client. */
export class BotStoatContext implements StoatContext {
private readonly membershipCache = new Map<string, MembershipInfo>();
constructor(private readonly client: Client) {}
getServerName(serverId: string): string | null {
return this.client.servers.get(serverId)?.name ?? null;
}
getVoiceChannel(channelId: string): VoiceChannelRef | null {
const channel = this.client.channels.get(channelId);
if (!channel?.isVoice) return null;
return { id: channel.id, name: channel.name };
}
listVoiceChannels(serverId: string): VoiceChannelRef[] {
const server = this.client.servers.get(serverId);
if (!server) return [];
return server.channels
.filter((channel) => channel.isVoice)
.map((channel) => ({ id: channel.id, name: channel.name }));
}
findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null {
const server = this.client.servers.get(serverId);
if (!server) return null;
for (const channel of server.channels) {
if (!channel.isVoice) continue;
if (channel.voiceParticipants.has(userId)) {
return { id: channel.id, name: channel.name };
}
}
return null;
}
/** Servers where both the bot and the given user are members. */
async listServersForUser(userId: string): Promise<ServerRef[]> {
const servers = [...this.client.servers.values()];
const checks = await Promise.all(
servers.map(async (server) => {
const info = await this.membership(server.id, userId);
if (!info.isMember) return null;
return {
id: server.id,
name: server.name,
iconUrl: server.icon?.createFileURL() ?? null,
} satisfies ServerRef;
}),
);
return checks.filter((entry): entry is ServerRef => entry !== null);
}
async isMember(serverId: string, userId: string): Promise<boolean> {
return (await this.membership(serverId, userId)).isMember;
}
async canControl(serverId: string, userId: string): Promise<boolean> {
const info = await this.membership(serverId, userId);
if (!info.isMember) return false;
if (!config.REQUIRE_DJ_ROLE) return true;
const server = this.client.servers.get(serverId);
if (!server) return false;
if (server.owner?.id === userId) return true;
const member = server.getMember(userId);
if (member?.hasPermission(server, "ManageServer")) return true;
const djRole = [...server.roles.entries()].find(
([, role]) => role.name.toLowerCase() === config.DJ_ROLE_NAME.toLowerCase(),
);
if (!djRole) return false;
return info.roles.includes(djRole[0]);
}
private async membership(serverId: string, userId: string): Promise<MembershipInfo> {
const key = `${serverId}:${userId}`;
const cached = this.membershipCache.get(key);
if (cached && Date.now() - cached.at < MEMBERSHIP_TTL_MS) return cached;
const cachedMember = this.client.servers.get(serverId)?.getMember(userId);
if (cachedMember) {
const info: MembershipInfo = { isMember: true, roles: cachedMember.roles ?? [], at: Date.now() };
this.membershipCache.set(key, info);
return info;
}
let info: MembershipInfo = { isMember: false, roles: [], at: Date.now() };
try {
const member = await fetchMember(serverId, userId);
if (member) info = { isMember: true, roles: member.roles ?? [], at: Date.now() };
} catch (err) {
log.warn({ err, serverId, userId }, "membership lookup failed");
}
this.membershipCache.set(key, info);
return info;
}
async sendMessage(channelId: string, content: string): Promise<void> {
const channel = this.client.channels.get(channelId);
if (!channel) return;
await channel.sendMessage(content);
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { LoopMode, Track } from "../types.js";
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const pad = (value: number) => value.toString().padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}
export function parseTimecode(input: string): number | null {
const trimmed = input.trim();
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed);
if (!match) return null;
const [, hours, minutes, seconds] = match;
return (
Number.parseInt(hours ?? "0", 10) * 3600 +
Number.parseInt(minutes ?? "0", 10) * 60 +
Number.parseInt(seconds ?? "0", 10)
);
}
export function progressBar(position: number, duration: number, width = 22): string {
if (duration <= 0) return "🔴 прямой эфир";
const ratio = Math.min(1, Math.max(0, position / duration));
const filled = Math.round(ratio * (width - 1));
const bar = `${"─".repeat(filled)}${"─".repeat(Math.max(0, width - 1 - filled))}`;
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
}
const SOURCE_LABEL: Record<Track["source"], string> = {
youtube: "YouTube",
soundcloud: "SoundCloud",
direct: "Ссылка",
local: "Медиатека",
};
export function trackLine(track: Track, index?: number): string {
const prefix = index === undefined ? "" : `**${index}.** `;
const author = track.author ? `${track.author}` : "";
const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title;
return `${prefix}${link}${author} \`[${formatDuration(track.duration)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`;
}
export function loopLabel(mode: LoopMode): string {
if (mode === "track") return "трек";
if (mode === "queue") return "очередь";
return "выключен";
}
+94
View File
@@ -0,0 +1,94 @@
import { Client, type Message } from "stoat.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
import type { MusicManager } from "../core/manager.js";
import { UserFacingError } from "../types.js";
import { findCommand, type CommandContext } from "./commands.js";
import { BotStoatContext } from "./context.js";
const log = logger.child({ mod: "bot" });
export interface Bot {
client: Client;
context: BotStoatContext;
stop(): Promise<void>;
}
export async function startBot(manager: MusicManager): Promise<Bot> {
const client = new Client({ baseURL: config.STOAT_API_URL });
const context = new BotStoatContext(client);
manager.attachStoat(context);
client.on("ready", () => {
log.info({ user: client.user?.username }, "bot is ready");
});
client.on("error", (error) => {
log.error({ err: error }, "client error");
});
client.on("disconnected", () => log.warn("gateway disconnected"));
client.on("messageCreate", (message) => {
void handleMessage(manager, message).catch((err) => {
log.error({ err }, "unhandled command failure");
});
});
await client.loginBot(config.STOAT_BOT_TOKEN);
return {
client,
context,
async stop() {
await manager.destroyAll();
},
};
}
async function handleMessage(manager: MusicManager, message: Message): Promise<void> {
const content = message.content?.trim();
if (!content || !content.startsWith(config.COMMAND_PREFIX)) return;
if (!message.authorId || message.author?.bot) return;
const serverId = message.server?.id;
const reply = async (text: string) => {
await message.channel?.sendMessage(text);
};
if (!serverId) {
await reply("Команды работают только внутри сервера.");
return;
}
const withoutPrefix = content.slice(config.COMMAND_PREFIX.length).trim();
const [rawName, ...args] = withoutPrefix.split(/\s+/);
if (!rawName) return;
const command = findCommand(rawName);
if (!command) return;
const ctx: CommandContext = {
manager,
serverId,
channelId: message.channelId,
actor: {
id: message.authorId,
username: message.member?.nickname || message.author?.username || "user",
},
args,
rest: withoutPrefix.slice(rawName.length).trim(),
reply,
};
log.debug({ command: command.name, user: ctx.actor.id, server: serverId }, "command");
try {
await command.run(ctx);
} catch (err) {
if (err instanceof UserFacingError) {
await reply(`⚠️ ${err.message}`);
return;
}
log.error({ err, command: command.name }, "command failed");
await reply("⚠️ Внутренняя ошибка, подробности в логах бота.");
}
}
+72
View File
@@ -0,0 +1,72 @@
import { readFileSync, existsSync } from "node:fs";
import { z } from "zod";
// Minimal .env loader so we don't need an extra dependency.
function loadDotEnv(path = ".env"): void {
if (!existsSync(path)) return;
for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
}
loadDotEnv();
const schema = z.object({
STOAT_API_URL: z.string().url(),
STOAT_BOT_TOKEN: z.string().min(1),
COMMAND_PREFIX: z.string().min(1).default("!"),
PORT: z.coerce.number().int().positive().default(3005),
HOST: z.string().default("0.0.0.0"),
PUBLIC_URL: z.string().url(),
JWT_SECRET: z.string().min(16),
SESSION_TTL_HOURS: z.coerce.number().positive().default(168),
YTDLP_PATH: z.string().default("yt-dlp"),
YTDLP_COOKIES: z.string().optional(),
LOCAL_MEDIA_DIR: z.string().optional(),
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(300),
DJ_ROLE_NAME: z.string().default("DJ"),
REQUIRE_DJ_ROLE: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
LOG_LEVEL: z.string().default("info"),
NODE_ENV: z.string().default("development"),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
console.error(`Invalid configuration, check your .env file:\n${issues}`);
process.exit(1);
}
export const config = {
...parsed.data,
STOAT_API_URL: parsed.data.STOAT_API_URL.replace(/\/+$/, ""),
PUBLIC_URL: parsed.data.PUBLIC_URL.replace(/\/+$/, ""),
isProduction: parsed.data.NODE_ENV === "production",
};
export type Config = typeof config;
+303
View File
@@ -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(),
};
}
}
+478
View File
@@ -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();
}
}
+61
View File
@@ -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);
}
+43
View File
@@ -0,0 +1,43 @@
import { startApiServer } from "./api/server.js";
import { startBot } from "./bot/index.js";
import { config } from "./config.js";
import { MusicManager } from "./core/manager.js";
import { logger } from "./logger.js";
import { checkYtDlp } from "./sources/index.js";
async function main(): Promise<void> {
const ytdlpVersion = await checkYtDlp();
if (ytdlpVersion) {
logger.info({ version: ytdlpVersion }, "yt-dlp detected");
} else {
logger.warn(
{ path: config.YTDLP_PATH },
"yt-dlp not found — YouTube/SoundCloud playback will fail; only direct links and local files will work",
);
}
const manager = new MusicManager();
const bot = await startBot(manager);
const app = await startApiServer({ manager, context: bot.context });
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "shutting down");
try {
await app.close();
await bot.stop();
} catch (err) {
logger.error({ err }, "shutdown failed");
} finally {
process.exit(0);
}
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("unhandledRejection", (err) => logger.error({ err }, "unhandled rejection"));
}
main().catch((err) => {
logger.fatal({ err }, "failed to start");
process.exit(1);
});
+16
View File
@@ -0,0 +1,16 @@
import pino from "pino";
import { config } from "./config.js";
export const logger = pino({
level: config.LOG_LEVEL,
...(config.isProduction
? {}
: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
}),
});
export type Logger = typeof logger;
+75
View File
@@ -0,0 +1,75 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { logger } from "../logger.js";
import type { Requester, Track } from "../types.js";
const log = logger.child({ mod: "direct" });
const AUDIO_CONTENT_TYPES = [
"audio/",
"application/ogg",
"application/x-mpegurl",
"application/vnd.apple.mpegurl",
"video/mp4",
"video/webm",
];
export interface ProbeResult {
isMedia: boolean;
isLive: boolean;
title: string | null;
}
/** Cheap HEAD probe used to tell "direct media URL" apart from "web page". */
export async function probe(url: string): Promise<ProbeResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch(url, {
method: "HEAD",
redirect: "follow",
signal: controller.signal,
headers: { "user-agent": "stoat-mbot/0.1", icy: "1" },
});
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
const isMedia = AUDIO_CONTENT_TYPES.some((type) => contentType.startsWith(type));
// Shoutcast/Icecast expose the station name and never a content length.
const icyName = res.headers.get("icy-name");
const isLive = isMedia && !res.headers.get("content-length");
return { isMedia: isMedia || Boolean(icyName), isLive: isLive || Boolean(icyName), title: icyName };
} catch (err) {
log.debug({ err, url }, "probe failed");
return { isMedia: false, isLive: false, title: null };
} finally {
clearTimeout(timer);
}
}
export function toTrack(url: string, requestedBy: Requester, probed: ProbeResult): Track {
let title = probed.title;
if (!title) {
try {
const name = path.basename(new URL(url).pathname);
title = decodeURIComponent(name) || new URL(url).hostname;
} catch {
title = url;
}
}
let host: string | null = null;
try {
host = new URL(url).hostname;
} catch {
host = null;
}
return {
id: randomUUID(),
title,
author: host,
duration: 0,
isLive: probed.isLive,
url,
thumbnail: null,
source: "direct",
requestedBy,
};
}
+143
View File
@@ -0,0 +1,143 @@
import type { Readable } from "node:stream";
import { config } from "../config.js";
import { UserFacingError, type Requester, type SearchResult, type Track } from "../types.js";
import * as direct from "./direct.js";
import * as local from "./local.js";
import * as ytdlp from "./ytdlp.js";
export { checkAvailable as checkYtDlp } from "./ytdlp.js";
export { isEnabled as isLocalLibraryEnabled, listFiles as listLocalFiles } from "./local.js";
const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"];
const SOUNDCLOUD_HOSTS = ["soundcloud.com", "on.soundcloud.com", "m.soundcloud.com"];
function asUrl(value: string): URL | null {
if (!/^https?:\/\//i.test(value)) return null;
try {
return new URL(value);
} catch {
return null;
}
}
function hostMatches(url: URL, hosts: string[]): boolean {
const host = url.hostname.replace(/^www\./, "");
return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`));
}
interface ParsedQuery {
text: string;
forced: "youtube" | "soundcloud" | "local" | null;
}
function parsePrefix(raw: string): ParsedQuery {
const trimmed = raw.trim();
const match = /^(yt|youtube|sc|soundcloud|local|file):\s*(.+)$/is.exec(trimmed);
if (!match) return { text: trimmed, forced: null };
const [, prefix, rest] = match as unknown as [string, string, string];
const key = prefix.toLowerCase();
if (key === "sc" || key === "soundcloud") return { text: rest.trim(), forced: "soundcloud" };
if (key === "local" || key === "file") return { text: rest.trim(), forced: "local" };
return { text: rest.trim(), forced: "youtube" };
}
/** Turns whatever a user typed into a playable set of tracks. */
export async function resolveQuery(
rawQuery: string,
requestedBy: Requester,
maxTracks = config.MAX_QUEUE_SIZE,
): Promise<SearchResult> {
const { text, forced } = parsePrefix(rawQuery);
if (!text) throw new UserFacingError("Укажите название трека или ссылку");
if (forced === "local") {
const tracks = await local.search(text, config.SEARCH_RESULT_LIMIT, requestedBy);
if (tracks.length === 0) throw new UserFacingError("В медиатеке ничего не найдено");
return { tracks: tracks.slice(0, 1), playlist: null };
}
const url = asUrl(text);
if (url) {
if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) {
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
}
const probed = await direct.probe(text);
if (probed.isMedia) {
return { tracks: [direct.toTrack(text, requestedBy, probed)], playlist: null };
}
// Not a raw media URL — let yt-dlp try its extractors (Bandcamp, Vimeo, ...).
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
}
if (local.isEnabled() && forced === null) {
const localHits = await local.search(text, 1, requestedBy);
if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null };
}
const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy);
if (tracks.length === 0) throw new UserFacingError("Ничего не найдено");
return { tracks, playlist: null };
}
/** Multi-result search used by the `search` command and the web panel. */
export async function searchTracks(
rawQuery: string,
requestedBy: Requester,
limit = config.SEARCH_RESULT_LIMIT,
): Promise<Track[]> {
const { text, forced } = parsePrefix(rawQuery);
if (!text) return [];
const url = asUrl(text);
if (url) {
const result = await resolveQuery(text, requestedBy);
return result.tracks;
}
if (forced === "local") return local.search(text, limit, requestedBy);
const [remote, localHits] = await Promise.all([
ytdlp.search(text, forced ?? "youtube", limit, requestedBy),
local.isEnabled() && forced === null
? local.search(text, 3, requestedBy).catch(() => [])
: Promise.resolve([]),
]);
return [...localHits, ...remote].slice(0, limit);
}
export interface PlaybackInput {
/** Either a file path / URL for ffmpeg, or a piped stream. */
input: string | Readable;
inputOptions: string[];
cleanup(): void;
}
const HTTP_RESILIENCE = [
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
];
/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */
export async function openPlayback(track: Track, seekSeconds = 0): Promise<PlaybackInput> {
const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
if (track.source === "local") {
const filePath = await local.assertInsideLibrary(track.url);
return { input: filePath, inputOptions: seekOptions, cleanup: () => {} };
}
if (track.source === "direct") {
return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
}
if (seekSeconds > 0) {
// Seeking over a pipe would mean decoding everything up to the offset, so we
// resolve the CDN URL instead and let ffmpeg do an HTTP range request.
const streamUrl = await ytdlp.resolveStreamUrl(track.url);
return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
}
const proc = ytdlp.openAudioStream(track.url);
return { input: proc.stream, inputOptions: [], cleanup: () => proc.kill() };
}
+96
View File
@@ -0,0 +1,96 @@
import { randomUUID } from "node:crypto";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { config } from "../config.js";
import { logger } from "../logger.js";
import { UserFacingError, type Requester, type Track } from "../types.js";
const log = logger.child({ mod: "local" });
const AUDIO_EXTENSIONS = new Set([".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav", ".wma", ".webm"]);
const CACHE_TTL_MS = 60_000;
let cache: { files: string[]; at: number } | null = null;
function libraryRoot(): string {
if (!config.LOCAL_MEDIA_DIR) throw new UserFacingError("Локальная медиатека не настроена (LOCAL_MEDIA_DIR)");
return path.resolve(config.LOCAL_MEDIA_DIR);
}
async function walk(dir: string, out: string[], depth = 0): Promise<void> {
if (depth > 6) return;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
log.warn({ err, dir }, "cannot read media directory");
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full, out, depth + 1);
} else if (entry.isFile() && AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
out.push(full);
}
}
}
export async function listFiles(force = false): Promise<string[]> {
const root = libraryRoot();
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.files;
const files: string[] = [];
await walk(root, files);
files.sort((a, b) => a.localeCompare(b));
cache = { files, at: Date.now() };
return files;
}
export function isEnabled(): boolean {
return Boolean(config.LOCAL_MEDIA_DIR);
}
/** Guards against path traversal — only files inside the library may be played. */
export async function assertInsideLibrary(filePath: string): Promise<string> {
const root = libraryRoot();
const resolved = path.resolve(filePath);
const relative = path.relative(root, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new UserFacingError("Файл вне медиатеки");
}
const info = await stat(resolved).catch(() => null);
if (!info?.isFile()) throw new UserFacingError("Файл не найден");
return resolved;
}
function toTrack(filePath: string, requestedBy: Requester): Track {
const root = libraryRoot();
const relative = path.relative(root, filePath);
const parsed = path.parse(relative);
const parentDir = path.basename(parsed.dir);
return {
id: randomUUID(),
title: parsed.name,
author: parentDir || null,
// Filled in from ffmpeg's codecData once playback starts.
duration: 0,
isLive: false,
url: filePath,
thumbnail: null,
source: "local",
requestedBy,
};
}
export async function search(query: string, limit: number, requestedBy: Requester): Promise<Track[]> {
const files = await listFiles();
const needle = query.trim().toLowerCase();
const matches = needle
? files.filter((file) => path.basename(file).toLowerCase().includes(needle))
: files;
return matches.slice(0, limit).map((file) => toTrack(file, requestedBy));
}
export async function resolvePath(filePath: string, requestedBy: Requester): Promise<Track> {
const resolved = await assertInsideLibrary(filePath);
return toTrack(resolved, requestedBy);
}
+221
View File
@@ -0,0 +1,221 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import type { Readable } from "node:stream";
import { config } from "../config.js";
import { logger } from "../logger.js";
import { UserFacingError, type Requester, type SearchResult, type SourceKind, type Track } from "../types.js";
const log = logger.child({ mod: "yt-dlp" });
/** Raw shape of the fields we consume from yt-dlp's JSON output. */
interface YtDlpEntry {
id?: string;
title?: string;
duration?: number | null;
uploader?: string | null;
channel?: string | null;
artist?: string | null;
webpage_url?: string | null;
url?: string | null;
original_url?: string | null;
thumbnail?: string | null;
thumbnails?: Array<{ url?: string }> | null;
is_live?: boolean | null;
live_status?: string | null;
extractor_key?: string | null;
ie_key?: string | null;
_type?: string;
entries?: YtDlpEntry[] | null;
}
function baseArgs(): string[] {
const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"];
if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES);
return args;
}
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new UserFacingError("yt-dlp не ответил вовремя"));
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => (stdout += chunk));
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => (stderr += chunk));
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT") {
reject(new UserFacingError(`yt-dlp не найден (${config.YTDLP_PATH}). Проверьте YTDLP_PATH.`));
return;
}
reject(err);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0 || stdout.trim().length > 0) {
resolve(stdout);
return;
}
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
reject(new UserFacingError(firstUsefulError(stderr)));
});
});
}
function firstUsefulError(stderr: string): string {
const line = stderr
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.toUpperCase().startsWith("ERROR"));
if (!line) return "Не удалось получить трек";
return line.replace(/^ERROR:\s*/i, "").slice(0, 300);
}
function sourceOf(entry: YtDlpEntry): SourceKind {
const key = (entry.extractor_key ?? entry.ie_key ?? "").toLowerCase();
if (key.includes("soundcloud")) return "soundcloud";
if (key.includes("youtube")) return "youtube";
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
if (url.includes("soundcloud.com")) return "soundcloud";
if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube";
return "direct";
}
function pickThumbnail(entry: YtDlpEntry): string | null {
if (entry.thumbnail) return entry.thumbnail;
const list = entry.thumbnails ?? [];
const last = list.at(-1);
return last?.url ?? null;
}
export function toTrack(entry: YtDlpEntry, requestedBy: Requester, fallbackSource?: SourceKind): Track {
const isLive = Boolean(entry.is_live) || entry.live_status === "is_live";
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
return {
id: randomUUID(),
title: entry.title?.trim() || "Без названия",
author: entry.artist ?? entry.uploader ?? entry.channel ?? null,
duration: isLive ? 0 : Math.max(0, Math.round(entry.duration ?? 0)),
isLive,
url,
thumbnail: pickThumbnail(entry),
source: fallbackSource ?? sourceOf(entry),
requestedBy,
};
}
function parseNdjson(stdout: string): YtDlpEntry[] {
const entries: YtDlpEntry[] = [];
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
entries.push(JSON.parse(trimmed) as YtDlpEntry);
} catch {
// yt-dlp occasionally interleaves non-JSON noise; skip it.
}
}
return entries;
}
export async function search(
query: string,
kind: "youtube" | "soundcloud",
limit: number,
requestedBy: Requester,
): Promise<Track[]> {
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
const stdout = await runYtDlp([
...baseArgs(),
"--flat-playlist",
"--dump-json",
`${prefix}${limit}:${query}`,
]);
return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind));
}
/** Resolves a URL that may point at a single track, a playlist, or an album. */
export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> {
const stdout = await runYtDlp([
...baseArgs(),
"--flat-playlist",
"--dump-single-json",
"--playlist-end",
String(maxTracks),
url,
]);
const root = parseNdjson(stdout)[0];
if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp");
if (root._type === "playlist" && Array.isArray(root.entries)) {
const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e));
if (entries.length === 0) throw new UserFacingError("Плейлист пуст или недоступен");
return {
tracks: entries.map((entry) => toTrack(entry, requestedBy)),
playlist: {
title: root.title?.trim() || "Плейлист",
url: root.webpage_url ?? url,
trackCount: entries.length,
},
};
}
return { tracks: [toTrack(root, requestedBy)], playlist: null };
}
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
export async function resolveStreamUrl(pageUrl: string): Promise<string> {
const stdout = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]);
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
return url;
}
export interface AudioProcess {
stream: Readable;
kill(): void;
}
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
export function openAudioStream(pageUrl: string): AudioProcess {
const child = spawn(
config.YTDLP_PATH,
[...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl],
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
);
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr = (stderr + chunk).slice(-2000);
});
child.on("close", (code) => {
if (code !== 0 && code !== null && stderr.trim()) {
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
}
});
return {
stream: child.stdout,
kill: () => {
if (child.exitCode === null) child.kill("SIGKILL");
},
};
}
export async function checkAvailable(): Promise<string | null> {
try {
const out = await runYtDlp(["--version"], 15_000);
return out.trim() || null;
} catch {
return null;
}
}
+148
View File
@@ -0,0 +1,148 @@
import { config } from "../config.js";
import { logger } from "../logger.js";
import { UserFacingError } from "../types.js";
const log = logger.child({ mod: "stoat-rest" });
export interface StoatUser {
_id: string;
username: string;
display_name?: string | null;
discriminator?: string;
avatar?: { _id: string } | null;
bot?: { owner: string } | null;
}
export interface StoatMember {
_id: { server: string; user: string };
nickname?: string | null;
roles?: string[] | null;
}
export interface StoatServer {
_id: string;
name: string;
owner: string;
roles?: Record<string, { name: string; rank?: number }> | null;
}
export type LoginResult =
| { kind: "success"; token: string; userId: string }
| { kind: "mfa"; ticket: string; methods: string[] };
async function request<T>(
path: string,
init: RequestInit & { token?: { type: "bot" | "session"; value: string } } = {},
): Promise<T> {
const { token, headers, ...rest } = init;
const finalHeaders: Record<string, string> = {
accept: "application/json",
...(headers as Record<string, string> | undefined),
};
if (token?.type === "bot") finalHeaders["x-bot-token"] = token.value;
if (token?.type === "session") finalHeaders["x-session-token"] = token.value;
if (rest.body && !finalHeaders["content-type"]) finalHeaders["content-type"] = "application/json";
const res = await fetch(`${config.STOAT_API_URL}${path}`, { ...rest, headers: finalHeaders });
const text = await res.text();
const body = text ? (JSON.parse(text) as unknown) : null;
if (!res.ok) {
log.debug({ path, status: res.status, body }, "stoat api error");
const type = (body as { type?: string } | null)?.type;
throw new StoatApiError(res.status, type ?? `HTTP ${res.status}`);
}
return body as T;
}
export class StoatApiError extends Error {
constructor(
readonly status: number,
readonly type: string,
) {
super(`Stoat API error: ${type}`);
this.name = "StoatApiError";
}
}
/**
* Authenticates a panel user against the very same accounts the Stoat instance
* uses. We never see or store password material: the credentials go straight to
* the instance's auth endpoint and the short-lived session is revoked as soon as
* we have confirmed who the user is.
*/
export async function loginWithPassword(
email: string,
password: string,
mfa?: { ticket: string; totpCode?: string; recoveryCode?: string },
): Promise<LoginResult> {
const body: Record<string, unknown> = { friendly_name: "stoat-mbot panel" };
if (mfa) {
body["mfa_ticket"] = mfa.ticket;
body["mfa_response"] = mfa.totpCode
? { totp_code: mfa.totpCode }
: { recovery_code: mfa.recoveryCode };
} else {
body["email"] = email;
body["password"] = password;
}
let response: { result: string; token?: string; user_id?: string; ticket?: string; allowed_methods?: string[] };
try {
response = await request("/auth/session/login", { method: "POST", body: JSON.stringify(body) });
} catch (err) {
if (err instanceof StoatApiError && (err.status === 401 || err.status === 400)) {
throw new UserFacingError("Неверный логин или пароль");
}
throw err;
}
if (response.result === "MFA") {
return {
kind: "mfa",
ticket: response.ticket ?? "",
methods: response.allowed_methods ?? [],
};
}
if (response.result === "Disabled") throw new UserFacingError("Аккаунт отключён");
if (!response.token || !response.user_id) throw new UserFacingError("Неожиданный ответ сервера авторизации");
return { kind: "success", token: response.token, userId: response.user_id };
}
export function fetchSelf(sessionToken: string): Promise<StoatUser> {
return request<StoatUser>("/users/@me", { token: { type: "session", value: sessionToken } });
}
export async function revokeSession(sessionToken: string): Promise<void> {
try {
await request("/auth/session/logout", {
method: "POST",
token: { type: "session", value: sessionToken },
});
} catch (err) {
log.warn({ err }, "could not revoke temporary session");
}
}
export function fetchUser(userId: string): Promise<StoatUser> {
return request<StoatUser>(`/users/${userId}`, {
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
});
}
export function fetchServer(serverId: string): Promise<StoatServer> {
return request<StoatServer>(`/servers/${serverId}`, {
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
});
}
export async function fetchMember(serverId: string, userId: string): Promise<StoatMember | null> {
try {
return await request<StoatMember>(`/servers/${serverId}/members/${userId}`, {
token: { type: "bot", value: config.STOAT_BOT_TOKEN },
});
} catch (err) {
if (err instanceof StoatApiError && err.status === 404) return null;
throw err;
}
}
+60
View File
@@ -0,0 +1,60 @@
export type SourceKind = "youtube" | "soundcloud" | "direct" | "local";
export interface Requester {
id: string;
username: string;
}
export interface Track {
/** Stable id used by the web UI to address a queue entry. */
id: string;
title: string;
author: string | null;
/** Seconds; 0 for live streams. */
duration: number;
isLive: boolean;
/** Human-facing page URL (or file path for local tracks). */
url: string;
thumbnail: string | null;
source: SourceKind;
requestedBy: Requester;
/** Direct media URL; resolved lazily right before playback. */
streamUrl?: string;
/** When streamUrl was resolved — CDN links expire, so we refresh them. */
streamUrlResolvedAt?: number;
}
export type LoopMode = "off" | "track" | "queue";
export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused";
export interface PlayerSnapshot {
serverId: string;
serverName: string | null;
voiceChannelId: string | null;
voiceChannelName: string | null;
textChannelId: string | null;
status: PlayerStatus;
current: Track | null;
/** Playback position of the current track, in seconds. */
position: number;
queue: Track[];
history: Track[];
volume: number;
loop: LoopMode;
shuffleUsed: boolean;
updatedAt: number;
}
export interface SearchResult {
tracks: Track[];
/** Set when a URL resolved to a playlist/album. */
playlist: { title: string; url: string; trackCount: number } | null;
}
export class UserFacingError extends Error {
constructor(message: string) {
super(message);
this.name = "UserFacingError";
}
}