Tie playback to the listener and keep the panel's view live
Four things people hit while using the panel: - The bot could be sent into a channel the requester was not in, and playback could be started from nowhere. Playback now follows the listener (REQUIRE_LISTENER, on by default), and the voice row shows where you and the bot are instead of offering a free channel picker. - Voice presence only refreshed on reload, because the SDK updates channel participants without emitting an event. The socket now watches that view and pushes changes. - The bot left the channel whenever the queue ran dry. It now leaves only after the last person does, EMPTY_TIMEOUT_SECONDS later (120 by default), and stays put while anyone is still listening. - A search that yielded nothing said nothing: yt-dlp can exit 0 with an empty result, so that case now reports the reason (or "nothing found"), and searches are logged with their result count. The queue moved under the player so search owns the left column, and elapsed time no longer renders as "LIVE" — formatDuration treated 0 as a live stream, which also affected the chat's progress bar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f22b08b350
commit
3e53f34374
+383
-362
@@ -1,362 +1,383 @@
|
||||
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;
|
||||
}
|
||||
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),
|
||||
requireListener: config.REQUIRE_LISTENER,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- 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) }));
|
||||
|
||||
// Stoat's SDK keeps voice participants up to date but emits no event for
|
||||
// them, so we watch our own view and push when this viewer's presence
|
||||
// changes — otherwise the panel only learns about it on reload.
|
||||
let lastPresence = "";
|
||||
const sendPresence = () => {
|
||||
const yourVoiceChannel = context.findUserVoiceChannel(serverId, session.userId);
|
||||
const voiceChannels = context.listVoiceChannels(serverId);
|
||||
const fingerprint = JSON.stringify([yourVoiceChannel, voiceChannels]);
|
||||
if (fingerprint === lastPresence) return;
|
||||
lastPresence = fingerprint;
|
||||
socket.send(JSON.stringify({ type: "presence", yourVoiceChannel, voiceChannels }));
|
||||
};
|
||||
sendPresence();
|
||||
const presenceTimer = setInterval(sendPresence, 3000);
|
||||
presenceTimer.unref?.();
|
||||
|
||||
socket.on("close", () => {
|
||||
clearInterval(presenceTimer);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user