Allow scoping the proxy to lookups only

YouTube's bot checks started once traffic went through a proxy exit IP,
while direct downloads had worked. YTDLP_PROXY_SCOPE=search keeps the
proxy on search and metadata — where it is needed to get past filtered
results — and lets the audio stream go out directly. Default stays "all",
so nothing changes unless it is set.

Searches also now run against a throwaway copy of the cookie file: they
run in parallel and yt-dlp rewrites that file on exit, so two of them
could clobber the jar the downloads depend on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 00:37:57 +03:00
co-authored by Claude Opus 5
parent 2ee907626e
commit 24dfcf9232
4 changed files with 75 additions and 14 deletions
+6
View File
@@ -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 и добавьте в
+10 -2
View File
@@ -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)
+5
View File
@@ -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()
+54 -12
View File
@@ -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<string | null> {
}
}
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<Track[]> {
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<string> {
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<string | null> {
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 },
);