diff --git a/.env.example b/.env.example index 8e70950..6d47a9f 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,12 @@ YTDLP_PATH=yt-dlp # стороне прокси — обычно нужен именно он). Можно с логином и паролем: # socks5h://user:pass@host:1080 # YTDLP_PROXY=socks5h://127.0.0.1:1080 + +# Где применять прокси: all — везде (по умолчанию), search — только поиск и +# метаданные, а сам аудиопоток качать напрямую. Второй вариант выручает, когда +# выходной IP прокси ловит от YouTube «Sign in to confirm you're not a bot», +# а без прокси скачивание работает. +# YTDLP_PROXY_SCOPE=search # # Внимание: 127.0.0.1 внутри контейнера — это сам контейнер. Если прокси поднят # на хосте, используйте socks5h://host.docker.internal:1080 и добавьте в diff --git a/README.md b/README.md index ffc1f2a..464fa47 100644 --- a/README.md +++ b/README.md @@ -292,8 +292,16 @@ YTDLP_PROXY=socks5h://127.0.0.1:1080 ``` 3. **Подозрительный IP.** Выходные узлы Psiphon и прочих публичных прокси YouTube знает и - проверяет чаще. Здесь помогают именно cookies; экспортировать их лучше из браузера, - работающего через тот же прокси, чтобы сессия не выглядела прыгающей между странами. + проверяет чаще. Если без прокси скачивание работало, а с ним пошли бот-проверки — оставьте + прокси только поиску: + + ```dotenv + YTDLP_PROXY_SCOPE=search + ``` + + Тогда через прокси идут поиск и метаданные (ради обхода фильтрации выдачи), а аудиопоток + качается напрямую. Второй путь — cookies, экспортированные из браузера, работающего через + тот же прокси, чтобы сессия не выглядела прыгающей между странами. ## Учётка YouTube (cookies) diff --git a/src/config.ts b/src/config.ts index c6760e0..80802b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -43,6 +43,11 @@ const schema = z.object({ YTDLP_PATH: z.string().default("yt-dlp"), YTDLP_COOKIES: z.string().optional(), + /** + * Where the proxy applies: "all" for every request, "search" to keep the audio + * stream direct — useful when a proxy exit IP triggers YouTube's bot checks. + */ + YTDLP_PROXY_SCOPE: z.enum(["all", "search"]).default("all"), /** Proxy for every yt-dlp request: http://, https://, socks5:// or socks5h://. */ YTDLP_PROXY: z .string() diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index 81435f5..7820a9f 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -2,7 +2,9 @@ import { spawn } from "node:child_process"; import { connect } from "node:net"; import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { access } from "node:fs/promises"; +import { access, copyFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import type { Readable } from "node:stream"; import { config } from "../config.js"; import { logger } from "../logger.js"; @@ -51,10 +53,21 @@ async function detectJsRuntime(): Promise { } } -function baseArgs(): string[] { +interface ArgOptions { + /** Streaming audio; the proxy may be scoped away from this path. */ + download?: boolean; + /** Cookie file to use instead of the configured one. */ + cookies?: string; +} + +function baseArgs(options: ArgOptions = {}): string[] { 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); + const cookies = options.cookies ?? config.YTDLP_COOKIES; + if (cookies) args.push("--cookies", cookies); + // A proxy exit IP is often flagged by YouTube, so it can be limited to the + // lookups that actually need it while playback goes out directly. + const proxied = config.YTDLP_PROXY_SCOPE === "all" || !options.download; + if (config.YTDLP_PROXY && proxied) args.push("--proxy", config.YTDLP_PROXY); for (const value of config.YTDLP_EXTRACTOR_ARGS?.split(";") ?? []) { const trimmed = value.trim(); if (trimmed) args.push("--extractor-args", trimmed); @@ -261,12 +274,21 @@ export async function search( requestedBy: Requester, ): Promise { const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch"; - const { stdout, stderr } = await runYtDlp([ - ...baseArgs(), - "--flat-playlist", - "--dump-json", - `${prefix}${limit}:${query}`, - ]); + // Searches run in parallel and yt-dlp rewrites the cookie file when it exits, + // so give each one a throwaway copy: only downloads update the real file. + const cookies = await copyCookies(); + let stdout: string; + let stderr: string; + try { + ({ stdout, stderr } = await runYtDlp([ + ...baseArgs(cookies ? { cookies } : {}), + "--flat-playlist", + "--dump-json", + `${prefix}${limit}:${query}`, + ])); + } finally { + if (cookies) await rm(cookies, { force: true }).catch(() => {}); + } const entries = parseNdjson(stdout); // yt-dlp can exit 0 with nothing to show (bot checks, region blocks). Without // this the panel would just render an empty list and say nothing at all. @@ -321,12 +343,32 @@ export async function resolveUrl( /** Resolves a direct, time-limited media URL for a page URL (used when seeking). */ export async function resolveStreamUrl(pageUrl: string): Promise { - const { stdout } = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]); + const { stdout } = await runYtDlp([ + ...baseArgs({ download: true }), + "-f", + "bestaudio/best", + "--no-playlist", + "-g", + pageUrl, + ]); const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean); if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток"); return url; } +/** Private, disposable copy of the cookie jar for concurrent reads. */ +async function copyCookies(): Promise { + if (!config.YTDLP_COOKIES) return null; + const target = path.join(tmpdir(), `mbot-cookies-${randomUUID()}.txt`); + try { + await copyFile(config.YTDLP_COOKIES, target); + return target; + } catch (err) { + log.warn({ err }, "could not copy cookie file, using it directly"); + return null; + } +} + export interface AudioProcess { stream: Readable; kill(): void; @@ -338,7 +380,7 @@ export interface AudioProcess { export function openAudioStream(pageUrl: string): AudioProcess { const child = spawn( config.YTDLP_PATH, - [...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl], + [...baseArgs({ download: true }), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, );