Fall back to SoundCloud when YouTube hides search results

YouTube applies restricted-mode filtering to programmatic search: the
same query that returns a clip in a signed-in browser comes back as an
empty list over the API, with exit code 0 and no message at all, and
cookies do not change it. Verified locally — "gudium идол" returns three
results, "gudium аудиопорно" returns none, and so does the plain
youtube.com/results page for the same words.

An empty YouTube search now retries on SoundCloud, and when both come up
empty the message says the query may have been filtered and suggests
pasting a link, instead of implying the track does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 00:07:20 +03:00
co-authored by Claude Opus 5
parent 3e53f34374
commit f84489e27e
5 changed files with 405 additions and 373 deletions
+11
View File
@@ -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 регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в
+367 -366
View File
@@ -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<void>;
}
export interface Command {
name: string;
aliases: string[];
usage: string;
description: string;
run(ctx: CommandContext): Promise<void>;
}
const SEARCH_TTL_MS = 5 * 60_000;
const searchSessions = new Map<string, { tracks: Track[]; expiresAt: number }>();
function rememberSearch(ctx: CommandContext, tracks: Track[]): void {
searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, {
tracks,
expiresAt: Date.now() + SEARCH_TTL_MS,
});
}
function recallSearch(ctx: CommandContext): Track[] | null {
const key = `${ctx.channelId}:${ctx.actor.id}`;
const entry = searchSessions.get(key);
if (!entry) return null;
if (entry.expiresAt < Date.now()) {
searchSessions.delete(key);
return null;
}
return entry.tracks;
}
async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise<void> {
if (!ctx.rest) throw new UserFacingError("Укажите название трека или ссылку");
const outcome = await ctx.manager.play(ctx.serverId, ctx.actor, ctx.rest, {
mode,
textChannelId: ctx.channelId,
});
if (outcome.playlist) {
await ctx.reply(
`📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`,
);
return;
}
const track = outcome.tracks[0];
if (!track) return;
if (outcome.startedNow || mode === "now") {
await ctx.reply(`▶️ Играю: ${trackLine(track)}`);
} else {
await ctx.reply(` В очередь (#${outcome.queuePosition}): ${trackLine(track)}`);
}
}
export const commands: Command[] = [
{
name: "play",
aliases: ["p", "играй"],
usage: "play <ссылка или название>",
description: "Добавить трек или плейлист в очередь",
run: (ctx) => playCommand(ctx, "append"),
},
{
name: "playnext",
aliases: ["pn", "next"],
usage: "playnext <ссылка или название>",
description: "Поставить трек следующим в очереди",
run: (ctx) => playCommand(ctx, "next"),
},
{
name: "playnow",
aliases: ["now"],
usage: "playnow <ссылка или название>",
description: "Включить трек немедленно",
run: (ctx) => playCommand(ctx, "now"),
},
{
name: "search",
aliases: ["s", "найди"],
usage: "search <запрос>",
description: "Найти треки и выбрать нужный командой pick",
async run(ctx) {
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
if (tracks.length === 0) {
await ctx.reply("Ничего не найдено.");
return;
}
rememberSearch(ctx, tracks);
const lines = tracks.map((track, index) => trackLine(track, index + 1));
await ctx.reply(
`🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``,
);
},
},
{
name: "pick",
aliases: ["выбрать"],
usage: "pick <номер>",
description: "Добавить трек из результатов поиска",
async run(ctx) {
const tracks = recallSearch(ctx);
if (!tracks) throw new UserFacingError("Сначала выполните поиск");
const index = Number.parseInt(ctx.args[0] ?? "", 10);
const track = tracks[index - 1];
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`);
const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], {
textChannelId: ctx.channelId,
});
await ctx.reply(
outcome.startedNow
? `▶️ Играю: ${trackLine(track)}`
: ` В очередь (#${outcome.queuePosition}): ${trackLine(track)}`,
);
},
},
{
name: "skip",
aliases: ["sk", "пропусти"],
usage: "skip [количество]",
description: "Пропустить текущий трек",
async run(ctx) {
const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1);
const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count);
await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста.");
},
},
{
name: "stop",
aliases: ["стоп"],
usage: "stop",
description: "Остановить воспроизведение и очистить очередь",
async run(ctx) {
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
await ctx.reply("⏹️ Остановлено, очередь очищена.");
},
},
{
name: "pause",
aliases: ["пауза"],
usage: "pause",
description: "Поставить на паузу / снять с паузы",
async run(ctx) {
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
},
},
{
name: "resume",
aliases: ["продолжи"],
usage: "resume",
description: "Продолжить воспроизведение",
async run(ctx) {
await ctx.manager.resume(ctx.serverId, ctx.actor.id);
await ctx.reply("▶️ Продолжаю.");
},
},
{
name: "queue",
aliases: ["q", "очередь"],
usage: "queue [страница]",
description: "Показать очередь",
async run(ctx) {
const snapshot = ctx.manager.snapshot(ctx.serverId);
if (!snapshot.current && snapshot.queue.length === 0) {
await ctx.reply("Очередь пуста.");
return;
}
const pageSize = 10;
const pages = Math.max(1, Math.ceil(snapshot.queue.length / pageSize));
const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1));
const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize);
const parts: string[] = [];
if (snapshot.current) {
parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`);
parts.push(progressBar(snapshot.position, snapshot.current.duration));
}
if (slice.length > 0) {
const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1));
parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`);
}
const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
parts.push(
`Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`,
);
await ctx.reply(parts.join("\n\n"));
},
},
{
name: "nowplaying",
aliases: ["np", "сейчас"],
usage: "nowplaying",
description: "Показать текущий трек",
async run(ctx) {
const snapshot = ctx.manager.snapshot(ctx.serverId);
if (!snapshot.current) {
await ctx.reply("Сейчас ничего не играет.");
return;
}
await ctx.reply(
`🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`,
);
},
},
{
name: "volume",
aliases: ["vol", "громкость"],
usage: "volume [0-200]",
description: "Показать или изменить громкость",
async run(ctx) {
if (ctx.args.length === 0) {
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
return;
}
const value = Number.parseInt(ctx.args[0] ?? "", 10);
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
},
},
{
name: "loop",
aliases: ["repeat", "повтор"],
usage: "loop [off|track|queue]",
description: "Режим повтора",
async run(ctx) {
const raw = (ctx.args[0] ?? "").toLowerCase();
const map: Record<string, LoopMode> = {
off: "off",
выкл: "off",
track: "track",
трек: "track",
one: "track",
queue: "queue",
очередь: "queue",
all: "queue",
};
const current = ctx.manager.snapshot(ctx.serverId).loop;
const nextMode =
map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off");
await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode);
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
},
},
{
name: "shuffle",
aliases: ["sh", "перемешай"],
usage: "shuffle",
description: "Перемешать очередь",
async run(ctx) {
await ctx.manager.shuffle(ctx.serverId, ctx.actor.id);
await ctx.reply("🔀 Очередь перемешана.");
},
},
{
name: "remove",
aliases: ["rm", "удали"],
usage: "remove <номер>",
description: "Убрать трек из очереди",
async run(ctx) {
const index = Number.parseInt(ctx.args[0] ?? "", 10);
const snapshot = ctx.manager.snapshot(ctx.serverId);
const track = snapshot.queue[index - 1];
if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди");
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
await ctx.reply(`🗑️ Удалено: **${track.title}**`);
},
},
{
name: "clear",
aliases: ["очисти"],
usage: "clear",
description: "Очистить очередь, не останавливая текущий трек",
async run(ctx) {
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
await ctx.reply("🧹 Очередь очищена.");
},
},
{
name: "seek",
aliases: ["перемотай"],
usage: "seek <мм:сс>",
description: "Перемотать текущий трек",
async run(ctx) {
const seconds = parseTimecode(ctx.rest);
if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`");
await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds);
await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`);
},
},
{
name: "join",
aliases: ["зайди"],
usage: "join",
description: "Позвать бота в ваш голосовой канал",
async run(ctx) {
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, {
textChannelId: ctx.channelId,
});
await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`);
},
},
{
name: "leave",
aliases: ["dc", "выйди"],
usage: "leave",
description: "Выйти из голосового канала",
async run(ctx) {
await ctx.manager.leave(ctx.serverId, ctx.actor.id);
await ctx.reply("👋 Вышел из голосового канала.");
},
},
{
name: "panel",
aliases: ["ui", "панель"],
usage: "panel",
description: "Получить ссылку на веб-панель",
async run(ctx) {
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
await ctx.reply(
`🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`,
);
},
},
{
name: "help",
aliases: ["h", "помощь"],
usage: "help",
description: "Показать список команд",
async run(ctx) {
const lines = commands.map(
(command) => `\`${config.COMMAND_PREFIX}${command.usage}\`${command.description}`,
);
await ctx.reply(
`**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`,
);
},
},
];
const lookup = new Map<string, Command>();
for (const command of commands) {
lookup.set(command.name, command);
for (const alias of command.aliases) lookup.set(alias, command);
}
export function findCommand(name: string): Command | undefined {
return lookup.get(name.toLowerCase());
}
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<void>;
}
export interface Command {
name: string;
aliases: string[];
usage: string;
description: string;
run(ctx: CommandContext): Promise<void>;
}
const SEARCH_TTL_MS = 5 * 60_000;
const searchSessions = new Map<string, { tracks: Track[]; expiresAt: number }>();
function rememberSearch(ctx: CommandContext, tracks: Track[]): void {
searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, {
tracks,
expiresAt: Date.now() + SEARCH_TTL_MS,
});
}
function recallSearch(ctx: CommandContext): Track[] | null {
const key = `${ctx.channelId}:${ctx.actor.id}`;
const entry = searchSessions.get(key);
if (!entry) return null;
if (entry.expiresAt < Date.now()) {
searchSessions.delete(key);
return null;
}
return entry.tracks;
}
async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise<void> {
if (!ctx.rest) throw new UserFacingError("Укажите название трека или ссылку");
const outcome = await ctx.manager.play(ctx.serverId, ctx.actor, ctx.rest, {
mode,
textChannelId: ctx.channelId,
});
if (outcome.playlist) {
await ctx.reply(
`📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`,
);
return;
}
const track = outcome.tracks[0];
if (!track) return;
if (outcome.startedNow || mode === "now") {
await ctx.reply(`▶️ Играю: ${trackLine(track)}`);
} else {
await ctx.reply(` В очередь (#${outcome.queuePosition}): ${trackLine(track)}`);
}
}
export const commands: Command[] = [
{
name: "play",
aliases: ["p", "играй"],
usage: "play <ссылка или название>",
description: "Добавить трек или плейлист в очередь",
run: (ctx) => playCommand(ctx, "append"),
},
{
name: "playnext",
aliases: ["pn", "next"],
usage: "playnext <ссылка или название>",
description: "Поставить трек следующим в очереди",
run: (ctx) => playCommand(ctx, "next"),
},
{
name: "playnow",
aliases: ["now"],
usage: "playnow <ссылка или название>",
description: "Включить трек немедленно",
run: (ctx) => playCommand(ctx, "now"),
},
{
name: "search",
aliases: ["s", "найди"],
usage: "search <запрос>",
description: "Найти треки и выбрать нужный командой pick",
async run(ctx) {
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
if (tracks.length === 0) {
await ctx.reply(`🔎 ${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<string, LoopMode> = {
off: "off",
выкл: "off",
track: "track",
трек: "track",
one: "track",
queue: "queue",
очередь: "queue",
all: "queue",
};
const current = ctx.manager.snapshot(ctx.serverId).loop;
const nextMode =
map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off");
await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode);
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
},
},
{
name: "shuffle",
aliases: ["sh", "перемешай"],
usage: "shuffle",
description: "Перемешать очередь",
async run(ctx) {
await ctx.manager.shuffle(ctx.serverId, ctx.actor.id);
await ctx.reply("🔀 Очередь перемешана.");
},
},
{
name: "remove",
aliases: ["rm", "удали"],
usage: "remove <номер>",
description: "Убрать трек из очереди",
async run(ctx) {
const index = Number.parseInt(ctx.args[0] ?? "", 10);
const snapshot = ctx.manager.snapshot(ctx.serverId);
const track = snapshot.queue[index - 1];
if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди");
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
await ctx.reply(`🗑️ Удалено: **${track.title}**`);
},
},
{
name: "clear",
aliases: ["очисти"],
usage: "clear",
description: "Очистить очередь, не останавливая текущий трек",
async run(ctx) {
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
await ctx.reply("🧹 Очередь очищена.");
},
},
{
name: "seek",
aliases: ["перемотай"],
usage: "seek <мм:сс>",
description: "Перемотать текущий трек",
async run(ctx) {
const seconds = parseTimecode(ctx.rest);
if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`");
await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds);
await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`);
},
},
{
name: "join",
aliases: ["зайди"],
usage: "join",
description: "Позвать бота в ваш голосовой канал",
async run(ctx) {
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, {
textChannelId: ctx.channelId,
});
await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`);
},
},
{
name: "leave",
aliases: ["dc", "выйди"],
usage: "leave",
description: "Выйти из голосового канала",
async run(ctx) {
await ctx.manager.leave(ctx.serverId, ctx.actor.id);
await ctx.reply("👋 Вышел из голосового канала.");
},
},
{
name: "panel",
aliases: ["ui", "панель"],
usage: "panel",
description: "Получить ссылку на веб-панель",
async run(ctx) {
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
await ctx.reply(
`🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`,
);
},
},
{
name: "help",
aliases: ["h", "помощь"],
usage: "help",
description: "Показать список команд",
async run(ctx) {
const lines = commands.map(
(command) => `\`${config.COMMAND_PREFIX}${command.usage}\`${command.description}`,
);
await ctx.reply(
`**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`,
);
},
},
];
const lookup = new Map<string, Command>();
for (const command of commands) {
lookup.set(command.name, command);
for (const alias of command.aliases) lookup.set(alias, command);
}
export function findCommand(name: string): Command | undefined {
return lookup.get(name.toLowerCase());
}
+2 -2
View File
@@ -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<ManagerEvents> {
});
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;
+24 -4
View File
@@ -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<Track[]> {
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;
+1 -1
View File
@@ -76,7 +76,7 @@ export function SearchPanel({ serverId, canControl, localLibrary, onError }: Pro
{results.length === 0 ? (
<div className="empty">
{searched
? `По запросу «${query}» ничего не нашлось`
? `По запросу «${query}» ничего не нашлось. YouTube иногда прячет результаты от ботов — попробуйте другие слова или вставьте прямую ссылку на трек.`
: lastAdded
? `Добавлено: ${lastAdded}`
: "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}