diff --git a/.env.example b/.env.example index 2713dd8..8e70950 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,11 @@ YTDLP_PATH=yt-dlp # на хосте, используйте socks5h://host.docker.internal:1080 и добавьте в # compose.yml к extra_hosts строку "host.docker.internal:host-gateway". +# JS-рантайм для yt-dlp: YouTube требует исполнять свой JS, иначе извлечение +# деградирует и начинаются бот-проверки. В образе уже есть Node, поэтому по +# умолчанию используется он. Пустое значение отключает флаг. +YTDLP_JS_RUNTIME=node + # Необязательно: дополнительные --extractor-args, через ";". # Помогает, когда YouTube не отдаёт форматы серверному IP: # YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari diff --git a/README.md b/README.md index 26b8a1a..ffc1f2a 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,25 @@ YTDLP_PROXY=socks5h://127.0.0.1:1080 Прямые ссылки и интернет-радио ffmpeg скачивает сам, и там прокси применяется только для схем `http://` и `https://`. +## «Sign in to confirm you're not a bot» + +Три причины, по которым YouTube так отвечает, в порядке частоты: + +1. **Нет JS-рантайма.** Современный yt-dlp обязан исполнять JavaScript плеера YouTube; без этого + извлечение деградирует и запросы выглядят как ботовые. В образе уже есть Node, и бот включает + его флагом `--js-runtimes node` (`YTDLP_JS_RUNTIME`). При старте в логе видно, что рантайм + подхвачен: `"jsRuntime":"node"`. Если там `null` — рантайм не определился, будет отдельное + предупреждение. +2. **Нет или протухли cookies** — см. раздел ниже. Проверить файл прямо в контейнере: + + ```bash + docker compose exec mbot yt-dlp --cookies /data/cookies.txt --simulate https://www.youtube.com/watch?v=dQw4w9WgXcQ + ``` + +3. **Подозрительный IP.** Выходные узлы Psiphon и прочих публичных прокси YouTube знает и + проверяет чаще. Здесь помогают именно cookies; экспортировать их лучше из браузера, + работающего через тот же прокси, чтобы сессия не выглядела прыгающей между странами. + ## Учётка YouTube (cookies) Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться diff --git a/src/config.ts b/src/config.ts index 945115e..c6760e0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,8 @@ const schema = z.object({ ), /** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */ YTDLP_EXTRACTOR_ARGS: z.string().optional(), + /** JS runtime yt-dlp uses for YouTube's player challenges; "" disables the flag. */ + YTDLP_JS_RUNTIME: z.string().default("node"), LOCAL_MEDIA_DIR: z.string().optional(), DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60), diff --git a/src/index.ts b/src/index.ts index 6517f39..c328b56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,9 +6,14 @@ import { logger } from "./logger.js"; import { checkCookies, checkProxy, checkYtDlp, describeProxy } from "./sources/index.js"; async function main(): Promise { - const ytdlpVersion = await checkYtDlp(); - if (ytdlpVersion) { - logger.info({ version: ytdlpVersion }, "yt-dlp detected"); + const ytdlp = await checkYtDlp(); + if (ytdlp) { + logger.info({ version: ytdlp.version, jsRuntime: ytdlp.jsRuntime }, "yt-dlp detected"); + if (!ytdlp.jsRuntime) { + logger.warn( + "no JavaScript runtime for yt-dlp — YouTube may answer with bot checks; set YTDLP_JS_RUNTIME", + ); + } } else { logger.warn( { path: config.YTDLP_PATH }, diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index fce2a7d..81435f5 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -31,8 +31,28 @@ interface YtDlpEntry { entries?: YtDlpEntry[] | null; } +/** + * Modern yt-dlp needs a JavaScript runtime for YouTube's player challenges; + * without one, extraction is degraded and YouTube starts answering with "Sign in + * to confirm you're not a bot". Our image already ships Node, so we point yt-dlp + * at it — but only once we have confirmed this build understands the flag. + */ +let jsRuntimeArgs: string[] = []; + +async function detectJsRuntime(): Promise { + if (!config.YTDLP_JS_RUNTIME) return null; + try { + const { stdout } = await runYtDlp(["--help"], 15_000); + if (!stdout.includes("--js-runtimes")) return null; + jsRuntimeArgs = ["--js-runtimes", config.YTDLP_JS_RUNTIME]; + return config.YTDLP_JS_RUNTIME; + } catch { + return null; + } +} + function baseArgs(): string[] { - const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"]; + const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color", ...jsRuntimeArgs]; if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES); if (config.YTDLP_PROXY) args.push("--proxy", config.YTDLP_PROXY); for (const value of config.YTDLP_EXTRACTOR_ARGS?.split(";") ?? []) { @@ -351,10 +371,17 @@ export function openAudioStream(pageUrl: string): AudioProcess { }; } -export async function checkAvailable(): Promise { +export interface YtDlpStatus { + version: string; + jsRuntime: string | null; +} + +export async function checkAvailable(): Promise { try { const { stdout } = await runYtDlp(["--version"], 15_000); - return stdout.trim() || null; + const version = stdout.trim(); + if (!version) return null; + return { version, jsRuntime: await detectJsRuntime() }; } catch { return null; }