diff --git a/README.md b/README.md index 08dbc05..f0bc988 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,17 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f - `YTDLP_COOKIES` — путь к `cookies.txt`, см. раздел ниже. - `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`. +## Поиск иногда не находит то, что видно в браузере + +YouTube может вернуть пустой список там, где в залогиненном браузере результаты есть: для +программного доступа применяется фильтрация (ограниченный режим), причём молча — yt-dlp +завершается успешно и без единого сообщения, куки не помогают. Признак: тот же запрос без +«спорного» слова находится нормально. + +Что делает бот: если YouTube ничего не отдал, поиск автоматически повторяется в SoundCloud, +а если пусто и там — сообщает об этом прямо, а не показывает пустой список. Надёжный обход — +вставить прямую ссылку на трек: по ссылке фильтр не применяется. + ## Обновление yt-dlp YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в diff --git a/src/bot/commands.ts b/src/bot/commands.ts index 2b00bb1..4d70389 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -1,366 +1,367 @@ -import { signPanelLink } from "../auth/tokens.js"; -import { config } from "../config.js"; -import type { MusicManager } from "../core/manager.js"; -import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js"; -import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js"; - -export interface CommandContext { - manager: MusicManager; - serverId: string; - channelId: string; - actor: Requester; - /** Raw text after the command name. */ - rest: string; - args: string[]; - reply(content: string): Promise; -} - -export interface Command { - name: string; - aliases: string[]; - usage: string; - description: string; - run(ctx: CommandContext): Promise; -} - -const SEARCH_TTL_MS = 5 * 60_000; -const searchSessions = new Map(); - -function rememberSearch(ctx: CommandContext, tracks: Track[]): void { - searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, { - tracks, - expiresAt: Date.now() + SEARCH_TTL_MS, - }); -} - -function recallSearch(ctx: CommandContext): Track[] | null { - const key = `${ctx.channelId}:${ctx.actor.id}`; - const entry = searchSessions.get(key); - if (!entry) return null; - if (entry.expiresAt < Date.now()) { - searchSessions.delete(key); - return null; - } - return entry.tracks; -} - -async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise { - if (!ctx.rest) throw new UserFacingError("Укажите название трека или ссылку"); - const outcome = await ctx.manager.play(ctx.serverId, ctx.actor, ctx.rest, { - mode, - textChannelId: ctx.channelId, - }); - - if (outcome.playlist) { - await ctx.reply( - `📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`, - ); - return; - } - const track = outcome.tracks[0]; - if (!track) return; - if (outcome.startedNow || mode === "now") { - await ctx.reply(`▶️ Играю: ${trackLine(track)}`); - } else { - await ctx.reply(`➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`); - } -} - -export const commands: Command[] = [ - { - name: "play", - aliases: ["p", "играй"], - usage: "play <ссылка или название>", - description: "Добавить трек или плейлист в очередь", - run: (ctx) => playCommand(ctx, "append"), - }, - { - name: "playnext", - aliases: ["pn", "next"], - usage: "playnext <ссылка или название>", - description: "Поставить трек следующим в очереди", - run: (ctx) => playCommand(ctx, "next"), - }, - { - name: "playnow", - aliases: ["now"], - usage: "playnow <ссылка или название>", - description: "Включить трек немедленно", - run: (ctx) => playCommand(ctx, "now"), - }, - { - name: "search", - aliases: ["s", "найди"], - usage: "search <запрос>", - description: "Найти треки и выбрать нужный командой pick", - async run(ctx) { - if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос"); - const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT); - if (tracks.length === 0) { - await ctx.reply("Ничего не найдено."); - return; - } - rememberSearch(ctx, tracks); - const lines = tracks.map((track, index) => trackLine(track, index + 1)); - await ctx.reply( - `🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``, - ); - }, - }, - { - name: "pick", - aliases: ["выбрать"], - usage: "pick <номер>", - description: "Добавить трек из результатов поиска", - async run(ctx) { - const tracks = recallSearch(ctx); - if (!tracks) throw new UserFacingError("Сначала выполните поиск"); - const index = Number.parseInt(ctx.args[0] ?? "", 10); - const track = tracks[index - 1]; - if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`); - const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], { - textChannelId: ctx.channelId, - }); - await ctx.reply( - outcome.startedNow - ? `▶️ Играю: ${trackLine(track)}` - : `➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`, - ); - }, - }, - { - name: "skip", - aliases: ["sk", "пропусти"], - usage: "skip [количество]", - description: "Пропустить текущий трек", - async run(ctx) { - const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1); - const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count); - await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста."); - }, - }, - { - name: "stop", - aliases: ["стоп"], - usage: "stop", - description: "Остановить воспроизведение и очистить очередь", - async run(ctx) { - await ctx.manager.stop(ctx.serverId, ctx.actor.id); - await ctx.reply("⏹️ Остановлено, очередь очищена."); - }, - }, - { - name: "pause", - aliases: ["пауза"], - usage: "pause", - description: "Поставить на паузу / снять с паузы", - async run(ctx) { - const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id); - await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю."); - }, - }, - { - name: "resume", - aliases: ["продолжи"], - usage: "resume", - description: "Продолжить воспроизведение", - async run(ctx) { - await ctx.manager.resume(ctx.serverId, ctx.actor.id); - await ctx.reply("▶️ Продолжаю."); - }, - }, - { - name: "queue", - aliases: ["q", "очередь"], - usage: "queue [страница]", - description: "Показать очередь", - async run(ctx) { - const snapshot = ctx.manager.snapshot(ctx.serverId); - if (!snapshot.current && snapshot.queue.length === 0) { - await ctx.reply("Очередь пуста."); - return; - } - const pageSize = 10; - const pages = Math.max(1, Math.ceil(snapshot.queue.length / pageSize)); - const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1)); - const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize); - - const parts: string[] = []; - if (snapshot.current) { - parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`); - parts.push(progressBar(snapshot.position, snapshot.current.duration)); - } - if (slice.length > 0) { - const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1)); - parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`); - } - const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0); - parts.push( - `Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`, - ); - await ctx.reply(parts.join("\n\n")); - }, - }, - { - name: "nowplaying", - aliases: ["np", "сейчас"], - usage: "nowplaying", - description: "Показать текущий трек", - async run(ctx) { - const snapshot = ctx.manager.snapshot(ctx.serverId); - if (!snapshot.current) { - await ctx.reply("Сейчас ничего не играет."); - return; - } - await ctx.reply( - `🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`, - ); - }, - }, - { - name: "volume", - aliases: ["vol", "громкость"], - usage: "volume [0-200]", - description: "Показать или изменить громкость", - async run(ctx) { - if (ctx.args.length === 0) { - await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`); - return; - } - const value = Number.parseInt(ctx.args[0] ?? "", 10); - if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200"); - await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value); - await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`); - }, - }, - { - name: "loop", - aliases: ["repeat", "повтор"], - usage: "loop [off|track|queue]", - description: "Режим повтора", - async run(ctx) { - const raw = (ctx.args[0] ?? "").toLowerCase(); - const map: Record = { - off: "off", - выкл: "off", - track: "track", - трек: "track", - one: "track", - queue: "queue", - очередь: "queue", - all: "queue", - }; - const current = ctx.manager.snapshot(ctx.serverId).loop; - const nextMode = - map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off"); - await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode); - await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`); - }, - }, - { - name: "shuffle", - aliases: ["sh", "перемешай"], - usage: "shuffle", - description: "Перемешать очередь", - async run(ctx) { - await ctx.manager.shuffle(ctx.serverId, ctx.actor.id); - await ctx.reply("🔀 Очередь перемешана."); - }, - }, - { - name: "remove", - aliases: ["rm", "удали"], - usage: "remove <номер>", - description: "Убрать трек из очереди", - async run(ctx) { - const index = Number.parseInt(ctx.args[0] ?? "", 10); - const snapshot = ctx.manager.snapshot(ctx.serverId); - const track = snapshot.queue[index - 1]; - if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди"); - await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id); - await ctx.reply(`🗑️ Удалено: **${track.title}**`); - }, - }, - { - name: "clear", - aliases: ["очисти"], - usage: "clear", - description: "Очистить очередь, не останавливая текущий трек", - async run(ctx) { - await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id); - await ctx.reply("🧹 Очередь очищена."); - }, - }, - { - name: "seek", - aliases: ["перемотай"], - usage: "seek <мм:сс>", - description: "Перемотать текущий трек", - async run(ctx) { - const seconds = parseTimecode(ctx.rest); - if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`"); - await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds); - await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`); - }, - }, - { - name: "join", - aliases: ["зайди"], - usage: "join", - description: "Позвать бота в ваш голосовой канал", - async run(ctx) { - await ctx.manager.assertControl(ctx.serverId, ctx.actor.id); - const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, { - textChannelId: ctx.channelId, - }); - await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`); - }, - }, - { - name: "leave", - aliases: ["dc", "выйди"], - usage: "leave", - description: "Выйти из голосового канала", - async run(ctx) { - await ctx.manager.leave(ctx.serverId, ctx.actor.id); - await ctx.reply("👋 Вышел из голосового канала."); - }, - }, - { - name: "panel", - aliases: ["ui", "панель"], - usage: "panel", - description: "Получить ссылку на веб-панель", - async run(ctx) { - await ctx.manager.assertControl(ctx.serverId, ctx.actor.id); - const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId); - await ctx.reply( - `🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`, - ); - }, - }, - { - name: "help", - aliases: ["h", "помощь"], - usage: "help", - description: "Показать список команд", - async run(ctx) { - const lines = commands.map( - (command) => `\`${config.COMMAND_PREFIX}${command.usage}\` — ${command.description}`, - ); - await ctx.reply( - `**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`, - ); - }, - }, -]; - -const lookup = new Map(); -for (const command of commands) { - lookup.set(command.name, command); - for (const alias of command.aliases) lookup.set(alias, command); -} - -export function findCommand(name: string): Command | undefined { - return lookup.get(name.toLowerCase()); -} +import { signPanelLink } from "../auth/tokens.js"; +import { config } from "../config.js"; +import type { MusicManager } from "../core/manager.js"; +import { NOTHING_FOUND } from "../sources/index.js"; +import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js"; +import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js"; + +export interface CommandContext { + manager: MusicManager; + serverId: string; + channelId: string; + actor: Requester; + /** Raw text after the command name. */ + rest: string; + args: string[]; + reply(content: string): Promise; +} + +export interface Command { + name: string; + aliases: string[]; + usage: string; + description: string; + run(ctx: CommandContext): Promise; +} + +const SEARCH_TTL_MS = 5 * 60_000; +const searchSessions = new Map(); + +function rememberSearch(ctx: CommandContext, tracks: Track[]): void { + searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, { + tracks, + expiresAt: Date.now() + SEARCH_TTL_MS, + }); +} + +function recallSearch(ctx: CommandContext): Track[] | null { + const key = `${ctx.channelId}:${ctx.actor.id}`; + const entry = searchSessions.get(key); + if (!entry) return null; + if (entry.expiresAt < Date.now()) { + searchSessions.delete(key); + return null; + } + return entry.tracks; +} + +async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise { + if (!ctx.rest) throw new UserFacingError("Укажите название трека или ссылку"); + const outcome = await ctx.manager.play(ctx.serverId, ctx.actor, ctx.rest, { + mode, + textChannelId: ctx.channelId, + }); + + if (outcome.playlist) { + await ctx.reply( + `📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`, + ); + return; + } + const track = outcome.tracks[0]; + if (!track) return; + if (outcome.startedNow || mode === "now") { + await ctx.reply(`▶️ Играю: ${trackLine(track)}`); + } else { + await ctx.reply(`➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`); + } +} + +export const commands: Command[] = [ + { + name: "play", + aliases: ["p", "играй"], + usage: "play <ссылка или название>", + description: "Добавить трек или плейлист в очередь", + run: (ctx) => playCommand(ctx, "append"), + }, + { + name: "playnext", + aliases: ["pn", "next"], + usage: "playnext <ссылка или название>", + description: "Поставить трек следующим в очереди", + run: (ctx) => playCommand(ctx, "next"), + }, + { + name: "playnow", + aliases: ["now"], + usage: "playnow <ссылка или название>", + description: "Включить трек немедленно", + run: (ctx) => playCommand(ctx, "now"), + }, + { + name: "search", + aliases: ["s", "найди"], + usage: "search <запрос>", + description: "Найти треки и выбрать нужный командой pick", + async run(ctx) { + if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос"); + const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT); + if (tracks.length === 0) { + await ctx.reply(`🔎 ${NOTHING_FOUND}`); + return; + } + rememberSearch(ctx, tracks); + const lines = tracks.map((track, index) => trackLine(track, index + 1)); + await ctx.reply( + `🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``, + ); + }, + }, + { + name: "pick", + aliases: ["выбрать"], + usage: "pick <номер>", + description: "Добавить трек из результатов поиска", + async run(ctx) { + const tracks = recallSearch(ctx); + if (!tracks) throw new UserFacingError("Сначала выполните поиск"); + const index = Number.parseInt(ctx.args[0] ?? "", 10); + const track = tracks[index - 1]; + if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`); + const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], { + textChannelId: ctx.channelId, + }); + await ctx.reply( + outcome.startedNow + ? `▶️ Играю: ${trackLine(track)}` + : `➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`, + ); + }, + }, + { + name: "skip", + aliases: ["sk", "пропусти"], + usage: "skip [количество]", + description: "Пропустить текущий трек", + async run(ctx) { + const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1); + const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count); + await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста."); + }, + }, + { + name: "stop", + aliases: ["стоп"], + usage: "stop", + description: "Остановить воспроизведение и очистить очередь", + async run(ctx) { + await ctx.manager.stop(ctx.serverId, ctx.actor.id); + await ctx.reply("⏹️ Остановлено, очередь очищена."); + }, + }, + { + name: "pause", + aliases: ["пауза"], + usage: "pause", + description: "Поставить на паузу / снять с паузы", + async run(ctx) { + const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id); + await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю."); + }, + }, + { + name: "resume", + aliases: ["продолжи"], + usage: "resume", + description: "Продолжить воспроизведение", + async run(ctx) { + await ctx.manager.resume(ctx.serverId, ctx.actor.id); + await ctx.reply("▶️ Продолжаю."); + }, + }, + { + name: "queue", + aliases: ["q", "очередь"], + usage: "queue [страница]", + description: "Показать очередь", + async run(ctx) { + const snapshot = ctx.manager.snapshot(ctx.serverId); + if (!snapshot.current && snapshot.queue.length === 0) { + await ctx.reply("Очередь пуста."); + return; + } + const pageSize = 10; + const pages = Math.max(1, Math.ceil(snapshot.queue.length / pageSize)); + const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1)); + const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize); + + const parts: string[] = []; + if (snapshot.current) { + parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`); + parts.push(progressBar(snapshot.position, snapshot.current.duration)); + } + if (slice.length > 0) { + const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1)); + parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`); + } + const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0); + parts.push( + `Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`, + ); + await ctx.reply(parts.join("\n\n")); + }, + }, + { + name: "nowplaying", + aliases: ["np", "сейчас"], + usage: "nowplaying", + description: "Показать текущий трек", + async run(ctx) { + const snapshot = ctx.manager.snapshot(ctx.serverId); + if (!snapshot.current) { + await ctx.reply("Сейчас ничего не играет."); + return; + } + await ctx.reply( + `🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`, + ); + }, + }, + { + name: "volume", + aliases: ["vol", "громкость"], + usage: "volume [0-200]", + description: "Показать или изменить громкость", + async run(ctx) { + if (ctx.args.length === 0) { + await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`); + return; + } + const value = Number.parseInt(ctx.args[0] ?? "", 10); + if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200"); + await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value); + await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`); + }, + }, + { + name: "loop", + aliases: ["repeat", "повтор"], + usage: "loop [off|track|queue]", + description: "Режим повтора", + async run(ctx) { + const raw = (ctx.args[0] ?? "").toLowerCase(); + const map: Record = { + off: "off", + выкл: "off", + track: "track", + трек: "track", + one: "track", + queue: "queue", + очередь: "queue", + all: "queue", + }; + const current = ctx.manager.snapshot(ctx.serverId).loop; + const nextMode = + map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off"); + await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode); + await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`); + }, + }, + { + name: "shuffle", + aliases: ["sh", "перемешай"], + usage: "shuffle", + description: "Перемешать очередь", + async run(ctx) { + await ctx.manager.shuffle(ctx.serverId, ctx.actor.id); + await ctx.reply("🔀 Очередь перемешана."); + }, + }, + { + name: "remove", + aliases: ["rm", "удали"], + usage: "remove <номер>", + description: "Убрать трек из очереди", + async run(ctx) { + const index = Number.parseInt(ctx.args[0] ?? "", 10); + const snapshot = ctx.manager.snapshot(ctx.serverId); + const track = snapshot.queue[index - 1]; + if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди"); + await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id); + await ctx.reply(`🗑️ Удалено: **${track.title}**`); + }, + }, + { + name: "clear", + aliases: ["очисти"], + usage: "clear", + description: "Очистить очередь, не останавливая текущий трек", + async run(ctx) { + await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id); + await ctx.reply("🧹 Очередь очищена."); + }, + }, + { + name: "seek", + aliases: ["перемотай"], + usage: "seek <мм:сс>", + description: "Перемотать текущий трек", + async run(ctx) { + const seconds = parseTimecode(ctx.rest); + if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`"); + await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds); + await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`); + }, + }, + { + name: "join", + aliases: ["зайди"], + usage: "join", + description: "Позвать бота в ваш голосовой канал", + async run(ctx) { + await ctx.manager.assertControl(ctx.serverId, ctx.actor.id); + const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, { + textChannelId: ctx.channelId, + }); + await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`); + }, + }, + { + name: "leave", + aliases: ["dc", "выйди"], + usage: "leave", + description: "Выйти из голосового канала", + async run(ctx) { + await ctx.manager.leave(ctx.serverId, ctx.actor.id); + await ctx.reply("👋 Вышел из голосового канала."); + }, + }, + { + name: "panel", + aliases: ["ui", "панель"], + usage: "panel", + description: "Получить ссылку на веб-панель", + async run(ctx) { + await ctx.manager.assertControl(ctx.serverId, ctx.actor.id); + const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId); + await ctx.reply( + `🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`, + ); + }, + }, + { + name: "help", + aliases: ["h", "помощь"], + usage: "help", + description: "Показать список команд", + async run(ctx) { + const lines = commands.map( + (command) => `\`${config.COMMAND_PREFIX}${command.usage}\` — ${command.description}`, + ); + await ctx.reply( + `**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`, + ); + }, + }, +]; + +const lookup = new Map(); +for (const command of commands) { + lookup.set(command.name, command); + for (const alias of command.aliases) lookup.set(alias, command); +} + +export function findCommand(name: string): Command | undefined { + return lookup.get(name.toLowerCase()); +} diff --git a/src/core/manager.ts b/src/core/manager.ts index e63e57c..7c3656a 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { config } from "../config.js"; import { logger } from "../logger.js"; -import { resolveQuery, searchTracks } from "../sources/index.js"; +import { NOTHING_FOUND, resolveQuery, searchTracks } from "../sources/index.js"; import { UserFacingError, type LoopMode, @@ -178,7 +178,7 @@ export class MusicManager extends EventEmitter { }); const result = await resolveQuery(query, requester, config.MAX_QUEUE_SIZE - player.queue.length); - if (result.tracks.length === 0) throw new UserFacingError("Ничего не найдено"); + if (result.tracks.length === 0) throw new UserFacingError(NOTHING_FOUND); const mode = options.mode ?? "append"; const wasIdle = !player.current; diff --git a/src/sources/index.ts b/src/sources/index.ts index 50d401a..429ea8b 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -74,9 +74,9 @@ export async function resolveQuery( if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null }; } - const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy); - if (tracks.length === 0) throw new UserFacingError("Ничего не найдено"); - return { tracks, playlist: null }; + const tracks = await searchWithFallback(text, forced, 1, requestedBy); + if (tracks.length === 0) throw new UserFacingError(NOTHING_FOUND); + return { tracks: tracks.slice(0, 1), playlist: null }; } /** Multi-result search used by the `search` command and the web panel. */ @@ -97,7 +97,7 @@ export async function searchTracks( if (forced === "local") return local.search(text, limit, requestedBy); const [remote, localHits] = await Promise.all([ - ytdlp.search(text, forced ?? "youtube", limit, requestedBy), + searchWithFallback(text, forced, limit, requestedBy), local.isEnabled() && forced === null ? local.search(text, 3, requestedBy).catch(() => []) : Promise.resolve([]), @@ -105,6 +105,26 @@ export async function searchTracks( return [...localHits, ...remote].slice(0, limit); } +/** + * YouTube silently returns nothing for queries its restricted mode dislikes — + * same query, same words, results in a browser but an empty list over the API. + * Falling back to SoundCloud rescues a good share of those. + */ +async function searchWithFallback( + text: string, + forced: "youtube" | "soundcloud" | "local" | null, + limit: number, + requestedBy: Requester, +): Promise { + const primary = forced === "soundcloud" ? "soundcloud" : "youtube"; + const tracks = await ytdlp.search(text, primary, limit, requestedBy); + if (tracks.length > 0 || forced !== null) return tracks; + return ytdlp.search(text, "soundcloud", limit, requestedBy).catch(() => []); +} + +export const NOTHING_FOUND = + "Ничего не найдено. YouTube иногда прячет результаты от ботов (например, из-за ограниченного режима) — попробуйте другие слова или вставьте ссылку на трек."; + export interface PlaybackInput { /** Either a file path / URL for ffmpeg, or a piped stream. */ input: string | Readable; diff --git a/web/src/components/SearchPanel.tsx b/web/src/components/SearchPanel.tsx index 3f8921d..5e7eda7 100644 --- a/web/src/components/SearchPanel.tsx +++ b/web/src/components/SearchPanel.tsx @@ -76,7 +76,7 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro {results.length === 0 ? (
{searched - ? `По запросу «${query}» ничего не нашлось` + ? `По запросу «${query}» ничего не нашлось. YouTube иногда прячет результаты от ботов — попробуйте другие слова или вставьте прямую ссылку на трек.` : lastAdded ? `Добавлено: ${lastAdded}` : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}