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