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:
co-authored by
Claude Opus 5
parent
2ee907626e
commit
24dfcf9232
@@ -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 и добавьте в
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+51
-9
@@ -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(),
|
||||
// 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 },
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user