diff --git a/.env.example b/.env.example index 8f86453..3e7fe40 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,10 @@ DEFAULT_VOLUME=60 MAX_QUEUE_SIZE=500 SEARCH_RESULT_LIMIT=10 -# Через сколько секунд простоя бот выходит из голосового канала (0 — никогда). -IDLE_TIMEOUT_SECONDS=300 +# Через сколько секунд после ухода ПОСЛЕДНЕГО человека бот покидает голосовой +# канал (0 — не выходить никогда). Пока в канале кто-то есть, бот остаётся, +# даже если очередь давно закончилась. +EMPTY_TIMEOUT_SECONDS=120 # --------------------------------------------------------------- Доступ ---- # false — управлять может любой участник сервера. @@ -60,6 +62,10 @@ IDLE_TIMEOUT_SECONDS=300 REQUIRE_DJ_ROLE=false DJ_ROLE_NAME=DJ +# true — позвать бота можно только в тот голосовой канал, где вы сами находитесь +# (и панель, и команды). false — разрешить выбирать канал вручную. +REQUIRE_LISTENER=true + # ----------------------------------------------------------------- Прочее --- LOG_LEVEL=info NODE_ENV=production diff --git a/README.md b/README.md index 024c6f0..08dbc05 100644 --- a/README.md +++ b/README.md @@ -181,9 +181,14 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f засорять канал. Для этого роли бота нужно право **Manage Messages** в настройках сервера (Settings → Roles → роль бота) или в правах самого канала. Без него команда всё равно отработает, а в логе будет `could not delete command message` с причиной отказа. +- `REQUIRE_LISTENER=true` (по умолчанию) — позвать бота можно только в тот голосовой канал, где + вы сами сидите: музыка идёт за слушателем, отправить бота «куда-то ещё» из панели нельзя. + Поставьте `false`, если хотите выбирать канал вручную. - `REQUIRE_DJ_ROLE=true` — управлять смогут только владелец сервера, обладатели `ManageServer` и роли из `DJ_ROLE_NAME`. По умолчанию `false`: играть может любой участник сервера. -- `IDLE_TIMEOUT_SECONDS` — через сколько секунд простоя (или пустого канала) бот выходит из войса. +- `EMPTY_TIMEOUT_SECONDS` — через сколько секунд после ухода последнего человека бот покидает + голосовой канал (по умолчанию 120, `0` — не выходить никогда). Пустая очередь поводом уйти + не считается: пока в канале кто-то есть, бот ждёт следующий трек. - `LOCAL_MEDIA_DIR` — примонтируйте том с музыкой и укажите путь внутри контейнера, тогда заработают `local:` и поиск по медиатеке. - `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже. diff --git a/src/api/server.ts b/src/api/server.ts index 81b8d97..0c914cc 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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(); - -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 { - 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 { - 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 { - 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 Promise> = { - 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>(); - - 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(); + +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 { + 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 { + 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 { + 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 Promise> = { + 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>(); + + 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; +} diff --git a/src/bot/format.ts b/src/bot/format.ts index a41d95c..45a89a9 100644 --- a/src/bot/format.ts +++ b/src/bot/format.ts @@ -1,52 +1,60 @@ -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 = { - 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 "выключен"; -} +import type { LoopMode, Track } from "../types.js"; + +/** Clock formatting for any position or length; 0 is a legitimate "0:00". */ +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + 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)}`; +} + +/** Track length as shown to people: live streams and unknown lengths are not clocks. */ +export function formatLength(track: { duration: number; isLive: boolean }): string { + if (track.isLive) return "LIVE"; + if (track.duration <= 0) return "—"; + return formatDuration(track.duration); +} + +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 = { + 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} \`[${formatLength(track)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`; +} + +export function loopLabel(mode: LoopMode): string { + if (mode === "track") return "трек"; + if (mode === "queue") return "очередь"; + return "выключен"; +} diff --git a/src/config.ts b/src/config.ts index 1f42770..72ad146 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,8 +50,14 @@ const schema = z.object({ 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), + /** Seconds to wait after the last human leaves the voice channel (0 — never leave). */ + EMPTY_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(120), DJ_ROLE_NAME: z.string().default("DJ"), + /** Only let people summon the bot into the voice channel they are sitting in. */ + REQUIRE_LISTENER: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true"), REQUIRE_DJ_ROLE: z .enum(["true", "false"]) .default("false") diff --git a/src/core/manager.ts b/src/core/manager.ts index 8ec32e0..e63e57c 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -141,10 +141,22 @@ export class MusicManager extends EventEmitter { 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)); + const listening = this.chat.findUserVoiceChannel(serverId, userId); + + if (config.REQUIRE_LISTENER) { + // Music follows the listener: you cannot push the bot into a channel you + // are not sitting in, and you cannot start playback from nowhere. + if (!listening) throw new UserFacingError("Сначала зайдите в голосовой канал"); + if (options.voiceChannelId && options.voiceChannelId !== listening.id) { + throw new UserFacingError("Бота можно позвать только в тот канал, где вы находитесь"); + } + } + + const target = config.REQUIRE_LISTENER + ? listening + : (options.voiceChannelId + ? this.chat.getVoiceChannel(options.voiceChannelId) + : (listening ?? (player.voiceChannelId ? this.chat.getVoiceChannel(player.voiceChannelId) : null))); if (!target) { throw new UserFacingError("Зайдите в голосовой канал или укажите его явно"); diff --git a/src/core/player.ts b/src/core/player.ts index 8091312..5166038 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -77,7 +77,7 @@ export class GuildPlayer extends EventEmitter { 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 leaveTimer: NodeJS.Timeout | null = null; private ticker: NodeJS.Timeout | null = null; private readonly log; @@ -182,7 +182,7 @@ export class GuildPlayer extends EventEmitter { }); connection.on("userleave", () => this.checkEmptyChannel()); connection.on("userLeave", () => this.checkEmptyChannel()); - connection.on("userJoin", () => this.clearIdleTimer()); + connection.on("userJoin", () => this.cancelLeaveTimer()); const media = new MediaPlayer(true); // revoice's #cleanUp() dereferences this.fProc unconditionally, so a second @@ -211,11 +211,12 @@ export class GuildPlayer extends EventEmitter { await connection.play(media); this.setStatus("idle"); + this.checkEmptyChannel(); this.log.info({ channelId }, "voice connection established"); } async leaveVoice(): Promise { - this.clearIdleTimer(); + this.cancelLeaveTimer(); this.stopTicker(); this.teardownPlayback(); this.current = null; @@ -242,8 +243,11 @@ export class GuildPlayer extends EventEmitter { private checkEmptyChannel(): void { if (!this.connection) return; - if (this.connection.getUsers().length > 0) return; - this.startIdleTimer("В канале никого не осталось"); + if (this.connection.getUsers().length > 0) { + this.cancelLeaveTimer(); + return; + } + this.startLeaveTimer(); } // -------------------------------------------------------------- playback --- @@ -272,7 +276,7 @@ export class GuildPlayer extends EventEmitter { private async startPlayback(track: Track, seekSeconds = 0): Promise { const media = this.assertReady(); - this.clearIdleTimer(); + this.cancelLeaveTimer(); this.teardownPlayback(); this.current = track; @@ -361,7 +365,6 @@ export class GuildPlayer extends EventEmitter { this.setStatus("idle"); this.publish(); if (finished) this.notify("⏹️ Очередь закончилась."); - this.startIdleTimer(); return; } @@ -385,7 +388,6 @@ export class GuildPlayer extends EventEmitter { this.stopTicker(); this.setStatus("idle"); this.publish(); - this.startIdleTimer(); } pause(): void { @@ -501,21 +503,25 @@ export class GuildPlayer extends EventEmitter { this.ticker = null; } - private clearIdleTimer(): void { - if (!this.idleTimer) return; - clearTimeout(this.idleTimer); - this.idleTimer = null; + private cancelLeaveTimer(): void { + if (!this.leaveTimer) return; + clearTimeout(this.leaveTimer); + this.leaveTimer = 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 ?? "Нет активности"}, выхожу из голосового канала.`); + /** + * Leaving is tied to the channel being empty, never to an idle queue: the bot + * stays put with people around, waiting for the next request. + */ + private startLeaveTimer(): void { + this.cancelLeaveTimer(); + if (config.EMPTY_TIMEOUT_SECONDS <= 0 || !this.connection) return; + this.leaveTimer = setTimeout(() => { + if (this.connection && this.connection.getUsers().length > 0) return; + this.notify("👋 В канале никого не осталось, выхожу."); void this.leaveVoice(); - }, config.IDLE_TIMEOUT_SECONDS * 1000); - this.idleTimer.unref?.(); + }, config.EMPTY_TIMEOUT_SECONDS * 1000); + this.leaveTimer.unref?.(); } async destroy(): Promise { diff --git a/src/index.ts b/src/index.ts index 486b349..b391425 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,12 @@ async function main(): Promise { ); } + if (process.env["IDLE_TIMEOUT_SECONDS"]) { + logger.warn( + "IDLE_TIMEOUT_SECONDS is gone: the bot now leaves only when the voice channel empties — use EMPTY_TIMEOUT_SECONDS", + ); + } + const cookies = await checkCookies(); if (cookies === "ok") { logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies"); diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index 432a7dc..4e0c4ff 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -61,8 +61,14 @@ export async function checkCookies(): Promise { } } -function runYtDlp(args: string[], timeoutMs = 45_000): Promise { - return new Promise((resolve, reject) => { +interface YtDlpRun { + stdout: string; + stderr: string; + code: number | null; +} + +function runYtDlp(args: string[], timeoutMs = 45_000): Promise { + return new Promise((resolve, reject) => { const child = spawn(config.YTDLP_PATH, args, { windowsHide: true }); let stdout = ""; let stderr = ""; @@ -88,7 +94,7 @@ function runYtDlp(args: string[], timeoutMs = 45_000): Promise { child.on("close", (code) => { clearTimeout(timer); if (code === 0 || stdout.trim().length > 0) { - resolve(stdout); + resolve({ stdout, stderr, code }); return; } log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed"); @@ -160,18 +166,26 @@ export async function search( requestedBy: Requester, ): Promise { const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch"; - const stdout = await runYtDlp([ + const { stdout, stderr } = await runYtDlp([ ...baseArgs(), "--flat-playlist", "--dump-json", `${prefix}${limit}:${query}`, ]); - return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind)); + const entries = parseNdjson(stdout); + // yt-dlp can exit 0 with nothing to show (bot checks, region blocks). Without + // this the panel would just render an empty list and say nothing at all. + if (entries.length === 0 && stderr.trim()) { + log.warn({ query, kind, stderr: stderr.slice(0, 500) }, "search returned nothing"); + throw new UserFacingError(firstUsefulError(stderr)); + } + log.info({ query, kind, count: entries.length }, "search"); + return entries.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 { - const stdout = await runYtDlp([ + const { stdout } = await runYtDlp([ ...baseArgs(), "--flat-playlist", "--dump-single-json", @@ -200,7 +214,7 @@ export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: /** Resolves a direct, time-limited media URL for a page URL (used when seeking). */ export async function resolveStreamUrl(pageUrl: string): Promise { - const stdout = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]); + 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; @@ -252,8 +266,8 @@ export function openAudioStream(pageUrl: string): AudioProcess { export async function checkAvailable(): Promise { try { - const out = await runYtDlp(["--version"], 15_000); - return out.trim() || null; + const { stdout } = await runYtDlp(["--version"], 15_000); + return stdout.trim() || null; } catch { return null; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 5d05a92..82b253e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,223 +1,229 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { api } from "./api"; -import { Login } from "./components/Login"; -import { NowPlaying } from "./components/NowPlaying"; -import { QueueList } from "./components/QueueList"; -import { SearchPanel } from "./components/SearchPanel"; -import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types"; - -const SERVER_KEY = "mbot.server"; - -export function App() { - const [me, setMe] = useState(null); - const [loading, setLoading] = useState(true); - const [serverId, setServerId] = useState(null); - const [state, setState] = useState(null); - const [position, setPosition] = useState(0); - const [voiceChannels, setVoiceChannels] = useState([]); - const [yourVoiceChannel, setYourVoiceChannel] = useState(null); - const [canControl, setCanControl] = useState(false); - const [error, setError] = useState(null); - const socketRef = useRef(null); - - /** Consumes the one-time link issued by the `!panel` chat command. */ - const consumeLinkToken = useCallback(async (): Promise => { - const params = new URLSearchParams(window.location.search); - const token = params.get("token"); - if (!token) return null; - try { - const result = await api.loginWithLink(token); - window.history.replaceState({}, "", "/"); - return result.serverId; - } catch (err) { - setError(err instanceof Error ? err.message : "Ссылка недействительна"); - window.history.replaceState({}, "", "/"); - return null; - } - }, []); - - const loadMe = useCallback( - async (preferredServer?: string | null) => { - try { - const profile = await api.me(); - setMe(profile); - const stored = preferredServer ?? localStorage.getItem(SERVER_KEY); - const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0]; - setServerId(chosen?.id ?? null); - } catch { - setMe(null); - } finally { - setLoading(false); - } - }, - [], - ); - - useEffect(() => { - void (async () => { - const fromLink = await consumeLinkToken(); - await loadMe(fromLink); - })(); - }, [consumeLinkToken, loadMe]); - - const applyServerState = useCallback((payload: ServerStateResponse) => { - setState(payload.state); - setPosition(payload.state.position); - setVoiceChannels(payload.voiceChannels); - setYourVoiceChannel(payload.yourVoiceChannel); - setCanControl(payload.canControl); - }, []); - - const refresh = useCallback( - async (id: string) => { - try { - applyServerState(await api.state(id)); - } catch (err) { - setError(err instanceof Error ? err.message : "Не удалось получить состояние"); - } - }, - [applyServerState], - ); - - // Live updates: the socket carries both full snapshots and 1 Hz position ticks. - useEffect(() => { - if (!serverId) return; - localStorage.setItem(SERVER_KEY, serverId); - void refresh(serverId); - - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`); - socketRef.current = socket; - - socket.onmessage = (event) => { - const payload = JSON.parse(event.data as string) as - | { type: "state"; state: PlayerState } - | { type: "position"; position: number }; - if (payload.type === "state") { - setState(payload.state); - setPosition(payload.state.position); - } else { - setPosition(payload.position); - } - }; - - return () => { - socket.close(); - socketRef.current = null; - }; - }, [serverId, refresh]); - - const runAction = useCallback( - async (action: string, payload: Record = {}) => { - if (!serverId) return; - setError(null); - try { - await api.action(serverId, action, payload); - } catch (err) { - setError(err instanceof Error ? err.message : "Действие не выполнено"); - } - }, - [serverId], - ); - - if (loading) return
Загрузка…
; - if (!me) return void loadMe()} />; - - return ( -
-
-
- - Stoat Music -
- - {me.servers.length > 0 && ( - - )} - -
- {me.user.username} - -
- - {error &&
{error}
} - - {!serverId || !state ? ( -
- Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу. -
- ) : ( -
-
- - { - void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message)); - }} - onMove={(track, index) => { - void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message)); - }} - onClear={() => void runAction("clear")} - /> -
- -
- void runAction(action, payload)} - onSeek={(seconds) => void runAction("seek", { position: seconds })} - onJoin={(channelId) => { - void api - .join(serverId, channelId) - .then(() => refresh(serverId)) - .catch((err: Error) => setError(err.message)); - }} - /> - - {state.history.length > 0 && ( -
-

История

-
    - {state.history.map((track, index) => ( -
  • - {index + 1} -
    -
    {track.title}
    -
    {track.author ?? ""}
    -
    -
  • - ))} -
-
- )} -
-
- )} -
- ); -} +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "./api"; +import { Login } from "./components/Login"; +import { NowPlaying } from "./components/NowPlaying"; +import { QueueList } from "./components/QueueList"; +import { SearchPanel } from "./components/SearchPanel"; +import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types"; + +const SERVER_KEY = "mbot.server"; + +export function App() { + const [me, setMe] = useState(null); + const [loading, setLoading] = useState(true); + const [serverId, setServerId] = useState(null); + const [state, setState] = useState(null); + const [position, setPosition] = useState(0); + const [voiceChannels, setVoiceChannels] = useState([]); + const [yourVoiceChannel, setYourVoiceChannel] = useState(null); + const [canControl, setCanControl] = useState(false); + const [error, setError] = useState(null); + const socketRef = useRef(null); + + /** Consumes the one-time link issued by the `!panel` chat command. */ + const consumeLinkToken = useCallback(async (): Promise => { + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + if (!token) return null; + try { + const result = await api.loginWithLink(token); + window.history.replaceState({}, "", "/"); + return result.serverId; + } catch (err) { + setError(err instanceof Error ? err.message : "Ссылка недействительна"); + window.history.replaceState({}, "", "/"); + return null; + } + }, []); + + const loadMe = useCallback( + async (preferredServer?: string | null) => { + try { + const profile = await api.me(); + setMe(profile); + const stored = preferredServer ?? localStorage.getItem(SERVER_KEY); + const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0]; + setServerId(chosen?.id ?? null); + } catch { + setMe(null); + } finally { + setLoading(false); + } + }, + [], + ); + + useEffect(() => { + void (async () => { + const fromLink = await consumeLinkToken(); + await loadMe(fromLink); + })(); + }, [consumeLinkToken, loadMe]); + + const applyServerState = useCallback((payload: ServerStateResponse) => { + setState(payload.state); + setPosition(payload.state.position); + setVoiceChannels(payload.voiceChannels); + setYourVoiceChannel(payload.yourVoiceChannel); + setCanControl(payload.canControl); + }, []); + + const refresh = useCallback( + async (id: string) => { + try { + applyServerState(await api.state(id)); + } catch (err) { + setError(err instanceof Error ? err.message : "Не удалось получить состояние"); + } + }, + [applyServerState], + ); + + // Live updates: the socket carries both full snapshots and 1 Hz position ticks. + useEffect(() => { + if (!serverId) return; + localStorage.setItem(SERVER_KEY, serverId); + void refresh(serverId); + + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`); + socketRef.current = socket; + + socket.onmessage = (event) => { + const payload = JSON.parse(event.data as string) as + | { type: "state"; state: PlayerState } + | { type: "position"; position: number } + | { type: "presence"; yourVoiceChannel: VoiceChannel | null; voiceChannels: VoiceChannel[] }; + if (payload.type === "state") { + setState(payload.state); + setPosition(payload.state.position); + } else if (payload.type === "presence") { + setYourVoiceChannel(payload.yourVoiceChannel); + setVoiceChannels(payload.voiceChannels); + } else { + setPosition(payload.position); + } + }; + + return () => { + socket.close(); + socketRef.current = null; + }; + }, [serverId, refresh]); + + const runAction = useCallback( + async (action: string, payload: Record = {}) => { + if (!serverId) return; + setError(null); + try { + await api.action(serverId, action, payload); + } catch (err) { + setError(err instanceof Error ? err.message : "Действие не выполнено"); + } + }, + [serverId], + ); + + if (loading) return
Загрузка…
; + if (!me) return void loadMe()} />; + + return ( +
+
+
+ + Stoat Music +
+ + {me.servers.length > 0 && ( + + )} + +
+ {me.user.username} + +
+ + {error &&
{error}
} + + {!serverId || !state ? ( +
+ Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу. +
+ ) : ( +
+
+ +
+ +
+ void runAction(action, payload)} + onSeek={(seconds) => void runAction("seek", { position: seconds })} + onJoin={(channelId) => { + void api + .join(serverId, channelId) + .then(() => refresh(serverId)) + .catch((err: Error) => setError(err.message)); + }} + /> + + { + void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message)); + }} + onMove={(track, index) => { + void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message)); + }} + onClear={() => void runAction("clear")} + /> + + {state.history.length > 0 && ( +
+

История

+
    + {state.history.map((track, index) => ( +
  • + {index + 1} +
    +
    {track.title}
    +
    {track.author ?? ""}
    +
    +
  • + ))} +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts index 05b15de..c79823e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,83 +1,91 @@ -import type { Me, ServerStateResponse, Track } from "./types"; - -export class ApiError extends Error {} - -async function request(path: string, init: RequestInit = {}): Promise { - const res = await fetch(path, { - credentials: "same-origin", - headers: init.body ? { "content-type": "application/json" } : undefined, - ...init, - }); - const text = await res.text(); - const body = text ? JSON.parse(text) : null; - if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`); - return body as T; -} - -export interface LoginResponse { - user?: { id: string; username: string }; - mfaRequired?: boolean; - ticket?: string; - methods?: string[]; -} - -export const api = { - me: () => request("/api/me"), - - login: (payload: { - email?: string; - password?: string; - mfaTicket?: string; - totpCode?: string; - recoveryCode?: string; - }) => request("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }), - - loginWithLink: (token: string) => - request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", { - method: "POST", - body: JSON.stringify({ token }), - }), - - logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }), - - state: (serverId: string) => request(`/api/servers/${serverId}/state`), - - search: (serverId: string, query: string) => - request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`), - - play: ( - serverId: string, - payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null }, - ) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }), - - action: (serverId: string, action: string, payload: Record = {}) => - request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, { - method: "POST", - body: JSON.stringify(payload), - }), - - join: (serverId: string, voiceChannelId: string | null) => - request<{ ok: true }>(`/api/servers/${serverId}/join`, { - method: "POST", - body: JSON.stringify({ voiceChannelId }), - }), - - removeTrack: (serverId: string, trackId: string) => - request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }), - - moveTrack: (serverId: string, trackId: string, index: number) => - request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, { - method: "POST", - body: JSON.stringify({ index }), - }), -}; - -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)}`; -} +import type { Me, ServerStateResponse, Track } from "./types"; + +export class ApiError extends Error {} + +async function request(path: string, init: RequestInit = {}): Promise { + const res = await fetch(path, { + credentials: "same-origin", + headers: init.body ? { "content-type": "application/json" } : undefined, + ...init, + }); + const text = await res.text(); + const body = text ? JSON.parse(text) : null; + if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`); + return body as T; +} + +export interface LoginResponse { + user?: { id: string; username: string }; + mfaRequired?: boolean; + ticket?: string; + methods?: string[]; +} + +export const api = { + me: () => request("/api/me"), + + login: (payload: { + email?: string; + password?: string; + mfaTicket?: string; + totpCode?: string; + recoveryCode?: string; + }) => request("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }), + + loginWithLink: (token: string) => + request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", { + method: "POST", + body: JSON.stringify({ token }), + }), + + logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }), + + state: (serverId: string) => request(`/api/servers/${serverId}/state`), + + search: (serverId: string, query: string) => + request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`), + + play: ( + serverId: string, + payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null }, + ) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }), + + action: (serverId: string, action: string, payload: Record = {}) => + request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, { + method: "POST", + body: JSON.stringify(payload), + }), + + join: (serverId: string, voiceChannelId: string | null) => + request<{ ok: true }>(`/api/servers/${serverId}/join`, { + method: "POST", + body: JSON.stringify({ voiceChannelId }), + }), + + removeTrack: (serverId: string, trackId: string) => + request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }), + + moveTrack: (serverId: string, trackId: string, index: number) => + request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, { + method: "POST", + body: JSON.stringify({ index }), + }), +}; + +/** Clock formatting for any position or length; 0 is a legitimate "0:00". */ +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + 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)}`; +} + +/** Track length as shown to people: live streams and unknown lengths are not clocks. */ +export function formatLength(track: { duration: number; isLive: boolean }): string { + if (track.isLive) return "LIVE"; + if (track.duration <= 0) return "—"; + return formatDuration(track.duration); +} diff --git a/web/src/components/NowPlaying.tsx b/web/src/components/NowPlaying.tsx index 24f0d70..f81290e 100644 --- a/web/src/components/NowPlaying.tsx +++ b/web/src/components/NowPlaying.tsx @@ -1,177 +1,200 @@ -import { useEffect, useState, type MouseEvent } from "react"; -import { formatDuration } from "../api"; -import type { LoopMode, PlayerState, VoiceChannel } from "../types"; - -interface Props { - state: PlayerState; - position: number; - canControl: boolean; - voiceChannels: VoiceChannel[]; - yourVoiceChannel: VoiceChannel | null; - onAction(action: string, payload?: Record): void; - onSeek(seconds: number): void; - onJoin(channelId: string | null): void; -} - -const STATUS_LABEL: Record = { - idle: "ожидание", - connecting: "подключение", - buffering: "буферизация", - playing: "играет", - paused: "пауза", -}; - -const LOOP_LABEL: Record = { - off: "🔁 выкл", - track: "🔂 трек", - queue: "🔁 очередь", -}; - -export function NowPlaying({ - state, - position, - canControl, - voiceChannels, - yourVoiceChannel, - onAction, - onSeek, - onJoin, -}: Props) { - const [volume, setVolume] = useState(state.volume); - const [channelId, setChannelId] = useState( - state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "", - ); - - useEffect(() => setVolume(state.volume), [state.volume]); - useEffect(() => { - if (state.voiceChannelId) setChannelId(state.voiceChannelId); - }, [state.voiceChannelId]); - - const track = state.current; - const duration = track?.duration ?? 0; - const ratio = duration > 0 ? Math.min(1, position / duration) : 0; - const isPlaying = state.status === "playing" || state.status === "buffering"; - - function seekFromClick(event: MouseEvent) { - if (!track || duration <= 0 || !canControl) return; - const rect = event.currentTarget.getBoundingClientRect(); - const fraction = (event.clientX - rect.left) / rect.width; - onSeek(Math.max(0, Math.min(duration - 1, fraction * duration))); - } - - const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off"; - - return ( -
-

- Сейчас играет{" "} - - {STATUS_LABEL[state.status]} - -

- -
- {track?.thumbnail ? ( - - ) : ( -
🎵
- )} -
-
- {track ? ( - /^https?:/.test(track.url) ? ( - - {track.title} - - ) : ( - track.title - ) - ) : ( - "Тишина" - )} -
-
- {track - ? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ") - : "Очередь пуста — найдите что-нибудь слева"} -
-
-
- -
-
- {track ? formatDuration(position) : "0:00"} - {track?.isLive ? "LIVE" : formatDuration(duration)} -
-
-
-
- -
- - - - - - -
- 🔊 - setVolume(Number(event.target.value))} - onMouseUp={() => onAction("volume", { volume })} - onTouchEnd={() => onAction("volume", { volume })} - /> - {volume}% -
-
- -
- - - -
-
- ); -} +import { useEffect, useState, type MouseEvent } from "react"; +import { formatDuration, formatLength } from "../api"; +import type { LoopMode, PlayerState, VoiceChannel } from "../types"; + +interface Props { + state: PlayerState; + position: number; + canControl: boolean; + voiceChannels: VoiceChannel[]; + yourVoiceChannel: VoiceChannel | null; + requireListener: boolean; + onAction(action: string, payload?: Record): void; + onSeek(seconds: number): void; + onJoin(channelId: string | null): void; +} + +const STATUS_LABEL: Record = { + idle: "ожидание", + connecting: "подключение", + buffering: "буферизация", + playing: "играет", + paused: "пауза", +}; + +const LOOP_LABEL: Record = { + off: "🔁 выкл", + track: "🔂 трек", + queue: "🔁 очередь", +}; + +export function NowPlaying({ + state, + position, + canControl, + voiceChannels, + yourVoiceChannel, + requireListener, + onAction, + onSeek, + onJoin, +}: Props) { + const [volume, setVolume] = useState(state.volume); + const [channelId, setChannelId] = useState( + state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "", + ); + + useEffect(() => setVolume(state.volume), [state.volume]); + useEffect(() => { + if (state.voiceChannelId) setChannelId(state.voiceChannelId); + }, [state.voiceChannelId]); + + const track = state.current; + const duration = track?.duration ?? 0; + const ratio = duration > 0 ? Math.min(1, position / duration) : 0; + const isPlaying = state.status === "playing" || state.status === "buffering"; + + function seekFromClick(event: MouseEvent) { + if (!track || duration <= 0 || !canControl) return; + const rect = event.currentTarget.getBoundingClientRect(); + const fraction = (event.clientX - rect.left) / rect.width; + onSeek(Math.max(0, Math.min(duration - 1, fraction * duration))); + } + + const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off"; + + return ( +
+

+ Сейчас играет{" "} + + {STATUS_LABEL[state.status]} + +

+ +
+ {track?.thumbnail ? ( + + ) : ( +
🎵
+ )} +
+
+ {track ? ( + /^https?:/.test(track.url) ? ( + + {track.title} + + ) : ( + track.title + ) + ) : ( + "Тишина" + )} +
+
+ {track + ? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ") + : "Очередь пуста — найдите что-нибудь слева"} +
+
+
+ +
+
+ {formatDuration(track ? position : 0)} + {track ? formatLength(track) : "0:00"} +
+
+
+
+ +
+ + + + + + +
+ 🔊 + setVolume(Number(event.target.value))} + onMouseUp={() => onAction("volume", { volume })} + onTouchEnd={() => onAction("volume", { volume })} + /> + {volume}% +
+
+ +
+ {requireListener ? ( + // Playback follows the listener, so there is nothing to choose here: + // the bot joins the channel you are sitting in. + + {yourVoiceChannel ? ( + <> + Вы в канале {yourVoiceChannel.name} + + ) : ( + "Вы не в голосовом канале" + )} + {state.voiceChannelName ? ` · бот в «${state.voiceChannelName}»` : " · бот не в канале"} + + ) : ( + + )} + + +
+
+ ); +} diff --git a/web/src/components/QueueList.tsx b/web/src/components/QueueList.tsx index 80bfed0..464fdcf 100644 --- a/web/src/components/QueueList.tsx +++ b/web/src/components/QueueList.tsx @@ -1,77 +1,75 @@ -import { formatDuration } from "../api"; -import type { Track } from "../types"; - -interface Props { - tracks: Track[]; - canControl: boolean; - onRemove(track: Track): void; - onMove(track: Track, index: number): void; - onClear(): void; -} - -export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) { - const total = tracks.reduce((acc, track) => acc + track.duration, 0); - - return ( -
-

- Очередь · {tracks.length} - {total > 0 ? ` · ${formatDuration(total)}` : ""} -

- - {tracks.length === 0 ? ( -
Очередь пуста
- ) : ( - <> -
    - {tracks.map((track, index) => ( -
  • - {index + 1} - {track.thumbnail ? :
    } -
    -
    {track.title}
    -
    - {[track.author, track.isLive ? "LIVE" : formatDuration(track.duration), track.requestedBy.username] - .filter(Boolean) - .join(" · ")} -
    -
    -
    - - - - -
    -
  • - ))} -
-
- -
- - )} -
- ); -} +import { formatDuration, formatLength } from "../api"; +import type { Track } from "../types"; + +interface Props { + tracks: Track[]; + canControl: boolean; + onRemove(track: Track): void; + onMove(track: Track, index: number): void; + onClear(): void; +} + +export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) { + const total = tracks.reduce((acc, track) => acc + track.duration, 0); + + return ( +
+

+ Очередь · {tracks.length} + {total > 0 ? ` · ${formatDuration(total)}` : ""} +

+ + {tracks.length === 0 ? ( +
Очередь пуста
+ ) : ( + <> +
    + {tracks.map((track, index) => ( +
  • + {index + 1} + {track.thumbnail ? :
    } +
    +
    {track.title}
    +
    + {[track.author, formatLength(track), track.requestedBy.username].filter(Boolean).join(" · ")} +
    +
    +
    + + + + +
    +
  • + ))} +
+
+ +
+ + )} +
+ ); +} diff --git a/web/src/components/SearchPanel.tsx b/web/src/components/SearchPanel.tsx index 35cdaa8..3f8921d 100644 --- a/web/src/components/SearchPanel.tsx +++ b/web/src/components/SearchPanel.tsx @@ -1,117 +1,124 @@ -import { useState, type FormEvent } from "react"; -import { api, formatDuration } from "../api"; -import type { Track } from "../types"; - -const SOURCE_BADGE: Record = { - youtube: "YouTube", - soundcloud: "SoundCloud", - direct: "Ссылка", - local: "Медиатека", -}; - -interface Props { - serverId: string; - canControl: boolean; - localLibrary: boolean; - onError(message: string | null): void; -} - -export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) { - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [busy, setBusy] = useState(false); - const [lastAdded, setLastAdded] = useState(null); - - async function submit(event: FormEvent) { - event.preventDefault(); - const value = query.trim(); - if (!value) return; - setBusy(true); - onError(null); - try { - if (/^https?:\/\//i.test(value)) { - await api.play(serverId, { query: value }); - setLastAdded(value); - setResults([]); - setQuery(""); - return; - } - const { tracks } = await api.search(serverId, value); - setResults(tracks); - } catch (err) { - onError(err instanceof Error ? err.message : "Поиск не удался"); - } finally { - setBusy(false); - } - } - - async function enqueue(track: Track, mode: "append" | "next" | "now") { - onError(null); - try { - await api.play(serverId, { trackIds: [track.id], mode }); - setLastAdded(track.title); - } catch (err) { - onError(err instanceof Error ? err.message : "Не удалось добавить трек"); - } - } - - return ( -
-

Поиск

-
- setQuery(event.target.value)} - placeholder="Название трека или ссылка…" - disabled={!canControl} - /> - -
- - {results.length === 0 ? ( -
- {lastAdded ? `Добавлено: ${lastAdded}` : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."} -
- ) : ( -
    - {results.map((track, index) => ( -
  • - {index + 1} - {track.thumbnail ? :
    } -
    -
    {track.title}
    -
    - {[track.author, track.isLive ? "LIVE" : formatDuration(track.duration)].filter(Boolean).join(" · ")} -
    -
    - {SOURCE_BADGE[track.source]} -
    - - - -
    -
  • - ))} -
- )} - -

- Префиксы: sc: — искать в SoundCloud, yt: — в YouTube - {localLibrary ? ( - <> - , local: — в локальной медиатеке - - ) : null} - . -

-
- ); -} +import { useState, type FormEvent } from "react"; +import { api, formatLength } from "../api"; +import type { Track } from "../types"; + +const SOURCE_BADGE: Record = { + youtube: "YouTube", + soundcloud: "SoundCloud", + direct: "Ссылка", + local: "Медиатека", +}; + +interface Props { + serverId: string; + canControl: boolean; + localLibrary: boolean; + onError(message: string | null): void; +} + +export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [busy, setBusy] = useState(false); + const [lastAdded, setLastAdded] = useState(null); + const [searched, setSearched] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const value = query.trim(); + if (!value) return; + setBusy(true); + onError(null); + try { + if (/^https?:\/\//i.test(value)) { + await api.play(serverId, { query: value }); + setLastAdded(value); + setResults([]); + setSearched(false); + setQuery(""); + return; + } + const { tracks } = await api.search(serverId, value); + setResults(tracks); + setSearched(true); + } catch (err) { + onError(err instanceof Error ? err.message : "Поиск не удался"); + } finally { + setBusy(false); + } + } + + async function enqueue(track: Track, mode: "append" | "next" | "now") { + onError(null); + try { + await api.play(serverId, { trackIds: [track.id], mode }); + setLastAdded(track.title); + } catch (err) { + onError(err instanceof Error ? err.message : "Не удалось добавить трек"); + } + } + + return ( +
+

Поиск

+
+ setQuery(event.target.value)} + placeholder="Название трека или ссылка…" + disabled={!canControl} + /> + +
+ + {results.length === 0 ? ( +
+ {searched + ? `По запросу «${query}» ничего не нашлось` + : lastAdded + ? `Добавлено: ${lastAdded}` + : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."} +
+ ) : ( +
    + {results.map((track, index) => ( +
  • + {index + 1} + {track.thumbnail ? :
    } +
    +
    {track.title}
    +
    + {[track.author, formatLength(track)].filter(Boolean).join(" · ")} +
    +
    + {SOURCE_BADGE[track.source]} +
    + + + +
    +
  • + ))} +
+ )} + +

+ Префиксы: sc: — искать в SoundCloud, yt: — в YouTube + {localLibrary ? ( + <> + , local: — в локальной медиатеке + + ) : null} + . +

+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index 48263d1..fdb5098 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,475 +1,511 @@ -:root { - --bg: #0f1014; - --bg-elev: #171922; - --bg-elev-2: #1e2130; - --line: #2a2e3f; - --text: #e8eaf2; - --muted: #9aa0b5; - --accent: #7b6cf6; - --accent-soft: rgba(123, 108, 246, 0.16); - --danger: #f2555a; - --ok: #3ecf8e; - --radius: 14px; - color-scheme: dark; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%); - color: var(--text); - font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; - min-height: 100vh; -} - -button { - font: inherit; - color: inherit; - border: 1px solid var(--line); - background: var(--bg-elev-2); - border-radius: 10px; - padding: 8px 12px; - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease; -} - -button:hover:not(:disabled) { - background: #262a3c; - border-color: #3a3f56; -} - -button:active:not(:disabled) { - transform: translateY(1px); -} - -button:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -button.primary { - background: var(--accent); - border-color: transparent; - color: #fff; - font-weight: 600; -} - -button.primary:hover:not(:disabled) { - background: #8b7dff; -} - -button.ghost { - background: transparent; -} - -button.icon { - width: 42px; - height: 42px; - display: grid; - place-items: center; - padding: 0; - border-radius: 12px; - font-size: 16px; -} - -button.icon.big { - width: 54px; - height: 54px; - font-size: 20px; -} - -button.active { - border-color: var(--accent); - background: var(--accent-soft); - color: #cfc7ff; -} - -input, -select { - font: inherit; - color: inherit; - background: var(--bg-elev-2); - border: 1px solid var(--line); - border-radius: 10px; - padding: 10px 12px; - width: 100%; -} - -input:focus, -select:focus { - outline: 2px solid var(--accent-soft); - border-color: var(--accent); -} - -.app { - max-width: 1180px; - margin: 0 auto; - padding: 20px 18px 60px; -} - -.topbar { - display: flex; - align-items: center; - gap: 14px; - padding: 12px 4px 20px; - flex-wrap: wrap; -} - -.brand { - font-weight: 700; - font-size: 18px; - letter-spacing: -0.01em; - display: flex; - align-items: center; - gap: 9px; -} - -.brand .dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 14px var(--accent); -} - -.spacer { - flex: 1; -} - -.topbar select { - width: auto; - min-width: 190px; -} - -.who { - color: var(--muted); - font-size: 14px; -} - -.layout { - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); - gap: 18px; - align-items: start; -} - -@media (max-width: 900px) { - .layout { - grid-template-columns: 1fr; - } -} - -.card { - background: var(--bg-elev); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 18px; -} - -.card + .card { - margin-top: 18px; -} - -.card h2 { - margin: 0 0 14px; - font-size: 14px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--muted); - font-weight: 600; -} - -.row { - display: flex; - gap: 10px; - align-items: center; -} - -.now { - display: flex; - gap: 16px; - align-items: center; -} - -.cover { - width: 96px; - height: 96px; - border-radius: 12px; - object-fit: cover; - background: var(--bg-elev-2); - flex-shrink: 0; -} - -.cover.placeholder { - display: grid; - place-items: center; - font-size: 30px; - color: var(--muted); -} - -.now-meta { - min-width: 0; - flex: 1; -} - -.now-title { - font-size: 19px; - font-weight: 650; - line-height: 1.25; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.now-title a { - color: inherit; - text-decoration: none; -} - -.now-title a:hover { - text-decoration: underline; -} - -.now-sub { - color: var(--muted); - font-size: 14px; - margin-top: 3px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.progress { - margin-top: 16px; -} - -.bar { - height: 8px; - border-radius: 99px; - background: var(--bg-elev-2); - cursor: pointer; - position: relative; - overflow: hidden; -} - -.bar > span { - position: absolute; - inset: 0 auto 0 0; - background: linear-gradient(90deg, var(--accent), #a78bfa); - border-radius: 99px; -} - -.times { - display: flex; - justify-content: space-between; - color: var(--muted); - font-size: 12px; - margin-top: 6px; - font-variant-numeric: tabular-nums; -} - -.controls { - display: flex; - align-items: center; - gap: 8px; - margin-top: 16px; - flex-wrap: wrap; -} - -.volume { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; - min-width: 170px; -} - -.volume input[type="range"] { - width: 110px; - padding: 0; - accent-color: var(--accent); - background: transparent; - border: none; -} - -.status-pill { - font-size: 12px; - padding: 4px 9px; - border-radius: 99px; - background: var(--bg-elev-2); - color: var(--muted); - border: 1px solid var(--line); -} - -.status-pill.playing { - color: var(--ok); - border-color: rgba(62, 207, 142, 0.4); -} - -.track-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 6px; - max-height: 60vh; - overflow-y: auto; -} - -.track { - display: flex; - align-items: center; - gap: 11px; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid transparent; -} - -.track:hover { - background: var(--bg-elev-2); - border-color: var(--line); -} - -.track .idx { - width: 22px; - text-align: right; - color: var(--muted); - font-size: 13px; - font-variant-numeric: tabular-nums; - flex-shrink: 0; -} - -.track .thumb { - width: 44px; - height: 44px; - border-radius: 8px; - object-fit: cover; - background: var(--bg-elev-2); - flex-shrink: 0; -} - -.track .info { - min-width: 0; - flex: 1; -} - -.track .title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.track .sub { - color: var(--muted); - font-size: 12.5px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.track .actions { - display: flex; - gap: 6px; - opacity: 0; - transition: opacity 0.15s ease; -} - -.track:hover .actions { - opacity: 1; -} - -.track .actions button { - padding: 5px 9px; - font-size: 13px; -} - -.badge { - font-size: 11px; - padding: 2px 7px; - border-radius: 6px; - background: var(--bg-elev-2); - border: 1px solid var(--line); - color: var(--muted); - flex-shrink: 0; -} - -.empty { - color: var(--muted); - text-align: center; - padding: 26px 10px; - font-size: 14px; -} - -.search-form { - display: flex; - gap: 8px; - margin-bottom: 12px; -} - -.hint { - color: var(--muted); - font-size: 12.5px; - margin-top: 10px; -} - -.error { - background: rgba(242, 85, 90, 0.12); - border: 1px solid rgba(242, 85, 90, 0.4); - color: #ffb4b6; - padding: 10px 12px; - border-radius: 10px; - margin-bottom: 12px; - font-size: 14px; -} - -.login-wrap { - min-height: 100vh; - display: grid; - place-items: center; - padding: 20px; -} - -.login { - width: 100%; - max-width: 380px; -} - -.login h1 { - font-size: 22px; - margin: 0 0 4px; -} - -.login p.sub { - color: var(--muted); - margin: 0 0 20px; - font-size: 14px; -} - -.login label { - display: block; - font-size: 13px; - color: var(--muted); - margin: 12px 0 6px; -} - -.login button { - width: 100%; - margin-top: 18px; -} - -.voice-row { - display: flex; - gap: 8px; - align-items: center; - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--line); -} - -.voice-row select { - flex: 1; -} +:root { + --bg: #0f1014; + --bg-elev: #171922; + --bg-elev-2: #1e2130; + --line: #2a2e3f; + --text: #e8eaf2; + --muted: #9aa0b5; + --accent: #7b6cf6; + --accent-soft: rgba(123, 108, 246, 0.16); + --danger: #f2555a; + --ok: #3ecf8e; + --radius: 14px; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%); + color: var(--text); + font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + min-height: 100vh; +} + +button { + font: inherit; + color: inherit; + border: 1px solid var(--line); + background: var(--bg-elev-2); + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease; +} + +button:hover:not(:disabled) { + background: #262a3c; + border-color: #3a3f56; +} + +button:active:not(:disabled) { + transform: translateY(1px); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + border-color: transparent; + color: #fff; + font-weight: 600; +} + +button.primary:hover:not(:disabled) { + background: #8b7dff; +} + +button.ghost { + background: transparent; +} + +button.icon { + width: 42px; + height: 42px; + display: grid; + place-items: center; + padding: 0; + border-radius: 12px; + font-size: 16px; +} + +button.icon.big { + width: 54px; + height: 54px; + font-size: 20px; +} + +button.active { + border-color: var(--accent); + background: var(--accent-soft); + color: #cfc7ff; +} + +input, +select { + font: inherit; + color: inherit; + background: var(--bg-elev-2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 12px; + width: 100%; +} + +input:focus, +select:focus { + outline: 2px solid var(--accent-soft); + border-color: var(--accent); +} + +.app { + max-width: 1180px; + margin: 0 auto; + padding: 20px 18px 60px; +} + +.topbar { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 4px 20px; + flex-wrap: wrap; +} + +.brand { + font-weight: 700; + font-size: 18px; + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: 9px; +} + +.brand .dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 14px var(--accent); +} + +.spacer { + flex: 1; +} + +.topbar select { + width: auto; + min-width: 190px; +} + +.who { + color: var(--muted); + font-size: 14px; +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 18px; + align-items: stretch; +} + +.layout > div { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } +} + +.card { + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px; +} + +/* The search column fills the available height so results have room to breathe. */ +.card.grow { + display: flex; + flex-direction: column; + min-height: 460px; +} + +.card.grow .track-list { + flex: 1; + max-height: none; +} + +.card.grow .empty { + flex: 1; + display: grid; + place-items: center; +} + +.card h2 { + margin: 0 0 14px; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + font-weight: 600; +} + +.row { + display: flex; + gap: 10px; + align-items: center; +} + +.now { + display: flex; + gap: 16px; + align-items: center; +} + +.cover { + width: 96px; + height: 96px; + border-radius: 12px; + object-fit: cover; + background: var(--bg-elev-2); + flex-shrink: 0; +} + +.cover.placeholder { + display: grid; + place-items: center; + font-size: 30px; + color: var(--muted); +} + +.now-meta { + min-width: 0; + flex: 1; +} + +.now-title { + font-size: 19px; + font-weight: 650; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.now-title a { + color: inherit; + text-decoration: none; +} + +.now-title a:hover { + text-decoration: underline; +} + +.now-sub { + color: var(--muted); + font-size: 14px; + margin-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress { + margin-top: 16px; +} + +.bar { + height: 8px; + border-radius: 99px; + background: var(--bg-elev-2); + cursor: pointer; + position: relative; + overflow: hidden; +} + +.bar > span { + position: absolute; + inset: 0 auto 0 0; + background: linear-gradient(90deg, var(--accent), #a78bfa); + border-radius: 99px; +} + +.times { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 12px; + margin-top: 6px; + font-variant-numeric: tabular-nums; +} + +.controls { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + flex-wrap: wrap; +} + +.volume { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + min-width: 170px; +} + +.volume input[type="range"] { + width: 110px; + padding: 0; + accent-color: var(--accent); + background: transparent; + border: none; +} + +.status-pill { + font-size: 12px; + padding: 4px 9px; + border-radius: 99px; + background: var(--bg-elev-2); + color: var(--muted); + border: 1px solid var(--line); +} + +.status-pill.playing { + color: var(--ok); + border-color: rgba(62, 207, 142, 0.4); +} + +.track-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + max-height: 60vh; + overflow-y: auto; +} + +.track { + display: flex; + align-items: center; + gap: 11px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid transparent; +} + +.track:hover { + background: var(--bg-elev-2); + border-color: var(--line); +} + +.track .idx { + width: 22px; + text-align: right; + color: var(--muted); + font-size: 13px; + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.track .thumb { + width: 44px; + height: 44px; + border-radius: 8px; + object-fit: cover; + background: var(--bg-elev-2); + flex-shrink: 0; +} + +.track .info { + min-width: 0; + flex: 1; +} + +.track .title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.track .sub { + color: var(--muted); + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.track .actions { + display: flex; + gap: 6px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.track:hover .actions { + opacity: 1; +} + +.track .actions button { + padding: 5px 9px; + font-size: 13px; +} + +.badge { + font-size: 11px; + padding: 2px 7px; + border-radius: 6px; + background: var(--bg-elev-2); + border: 1px solid var(--line); + color: var(--muted); + flex-shrink: 0; +} + +.empty { + color: var(--muted); + text-align: center; + padding: 26px 10px; + font-size: 14px; +} + +.search-form { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.hint { + color: var(--muted); + font-size: 12.5px; + margin-top: 10px; +} + +.error { + background: rgba(242, 85, 90, 0.12); + border: 1px solid rgba(242, 85, 90, 0.4); + color: #ffb4b6; + padding: 10px 12px; + border-radius: 10px; + margin-bottom: 12px; + font-size: 14px; +} + +.login-wrap { + min-height: 100vh; + display: grid; + place-items: center; + padding: 20px; +} + +.login { + width: 100%; + max-width: 380px; +} + +.login h1 { + font-size: 22px; + margin: 0 0 4px; +} + +.login p.sub { + color: var(--muted); + margin: 0 0 20px; + font-size: 14px; +} + +.login label { + display: block; + font-size: 13px; + color: var(--muted); + margin: 12px 0 6px; +} + +.login button { + width: 100%; + margin-top: 18px; +} + +.voice-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.voice-row select { + flex: 1; +} + +.voice-status { + flex: 1; + min-width: 0; + color: var(--muted); + font-size: 13.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.voice-status strong { + color: var(--text); + font-weight: 600; +} diff --git a/web/src/types.ts b/web/src/types.ts index cf40489..9ea3ac7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,54 +1,54 @@ -export type SourceKind = "youtube" | "soundcloud" | "direct" | "local"; -export type LoopMode = "off" | "track" | "queue"; -export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused"; - -export interface Track { - id: string; - title: string; - author: string | null; - duration: number; - isLive: boolean; - url: string; - thumbnail: string | null; - source: SourceKind; - requestedBy: { id: string; username: string }; -} - -export interface PlayerState { - serverId: string; - serverName: string | null; - voiceChannelId: string | null; - voiceChannelName: string | null; - status: PlayerStatus; - current: Track | null; - position: number; - queue: Track[]; - history: Track[]; - volume: number; - loop: LoopMode; - updatedAt: number; -} - -export interface VoiceChannel { - id: string; - name: string; -} - -export interface ServerRef { - id: string; - name: string; - iconUrl: string | null; -} - -export interface Me { - user: { id: string; username: string }; - servers: ServerRef[]; - features: { localLibrary: boolean }; -} - -export interface ServerStateResponse { - state: PlayerState; - voiceChannels: VoiceChannel[]; - yourVoiceChannel: VoiceChannel | null; - canControl: boolean; -} +export type SourceKind = "youtube" | "soundcloud" | "direct" | "local"; +export type LoopMode = "off" | "track" | "queue"; +export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused"; + +export interface Track { + id: string; + title: string; + author: string | null; + duration: number; + isLive: boolean; + url: string; + thumbnail: string | null; + source: SourceKind; + requestedBy: { id: string; username: string }; +} + +export interface PlayerState { + serverId: string; + serverName: string | null; + voiceChannelId: string | null; + voiceChannelName: string | null; + status: PlayerStatus; + current: Track | null; + position: number; + queue: Track[]; + history: Track[]; + volume: number; + loop: LoopMode; + updatedAt: number; +} + +export interface VoiceChannel { + id: string; + name: string; +} + +export interface ServerRef { + id: string; + name: string; + iconUrl: string | null; +} + +export interface Me { + user: { id: string; username: string }; + servers: ServerRef[]; + features: { localLibrary: boolean; requireListener: boolean }; +} + +export interface ServerStateResponse { + state: PlayerState; + voiceChannels: VoiceChannel[]; + yourVoiceChannel: VoiceChannel | null; + canControl: boolean; +}