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_COOKIES` — путь к `cookies.txt`, см. раздел ниже.
- `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`. - `YTDLP_EXTRACTOR_ARGS` — дополнительные `--extractor-args` через `;`.
## Поиск иногда не находит то, что видно в браузере
YouTube может вернуть пустой список там, где в залогиненном браузере результаты есть: для
программного доступа применяется фильтрация (ограниченный режим), причём молча — yt-dlp
завершается успешно и без единого сообщения, куки не помогают. Признак: тот же запрос без
«спорного» слова находится нормально.
Что делает бот: если YouTube ничего не отдал, поиск автоматически повторяется в SoundCloud,
а если пусто и там — сообщает об этом прямо, а не показывает пустой список. Надёжный обход —
вставить прямую ссылку на трек: по ссылке фильтр не применяется.
## Обновление yt-dlp ## Обновление yt-dlp
YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в YouTube регулярно ломает экстракторы, и симптом всегда один: трек находится, но не играет, а в
+2 -1
View File
@@ -1,6 +1,7 @@
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 { NOTHING_FOUND } from "../sources/index.js";
import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js"; import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js";
import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js"; import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js";
@@ -97,7 +98,7 @@ export const commands: Command[] = [
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос"); if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT); const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
if (tracks.length === 0) { if (tracks.length === 0) {
await ctx.reply("Ничего не найдено."); await ctx.reply(`🔎 ${NOTHING_FOUND}`);
return; return;
} }
rememberSearch(ctx, tracks); rememberSearch(ctx, tracks);
+2 -2
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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}`
: "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."} : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}