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
|
# socks5h://user:pass@host:1080
|
||||||
# YTDLP_PROXY=socks5h://127.0.0.1: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 внутри контейнера — это сам контейнер. Если прокси поднят
|
# Внимание: 127.0.0.1 внутри контейнера — это сам контейнер. Если прокси поднят
|
||||||
# на хосте, используйте socks5h://host.docker.internal:1080 и добавьте в
|
# на хосте, используйте socks5h://host.docker.internal:1080 и добавьте в
|
||||||
|
|||||||
@@ -292,8 +292,16 @@ YTDLP_PROXY=socks5h://127.0.0.1:1080
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. **Подозрительный IP.** Выходные узлы Psiphon и прочих публичных прокси YouTube знает и
|
3. **Подозрительный IP.** Выходные узлы Psiphon и прочих публичных прокси YouTube знает и
|
||||||
проверяет чаще. Здесь помогают именно cookies; экспортировать их лучше из браузера,
|
проверяет чаще. Если без прокси скачивание работало, а с ним пошли бот-проверки — оставьте
|
||||||
работающего через тот же прокси, чтобы сессия не выглядела прыгающей между странами.
|
прокси только поиску:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
YTDLP_PROXY_SCOPE=search
|
||||||
|
```
|
||||||
|
|
||||||
|
Тогда через прокси идут поиск и метаданные (ради обхода фильтрации выдачи), а аудиопоток
|
||||||
|
качается напрямую. Второй путь — cookies, экспортированные из браузера, работающего через
|
||||||
|
тот же прокси, чтобы сессия не выглядела прыгающей между странами.
|
||||||
|
|
||||||
## Учётка YouTube (cookies)
|
## Учётка YouTube (cookies)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ const schema = z.object({
|
|||||||
|
|
||||||
YTDLP_PATH: z.string().default("yt-dlp"),
|
YTDLP_PATH: z.string().default("yt-dlp"),
|
||||||
YTDLP_COOKIES: z.string().optional(),
|
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://. */
|
/** Proxy for every yt-dlp request: http://, https://, socks5:// or socks5h://. */
|
||||||
YTDLP_PROXY: z
|
YTDLP_PROXY: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
+51
-9
@@ -2,7 +2,9 @@ import { spawn } from "node:child_process";
|
|||||||
import { connect } from "node:net";
|
import { connect } from "node:net";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { constants } from "node:fs";
|
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 type { Readable } from "node:stream";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.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];
|
const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color", ...jsRuntimeArgs];
|
||||||
if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES);
|
const cookies = options.cookies ?? config.YTDLP_COOKIES;
|
||||||
if (config.YTDLP_PROXY) args.push("--proxy", config.YTDLP_PROXY);
|
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(";") ?? []) {
|
for (const value of config.YTDLP_EXTRACTOR_ARGS?.split(";") ?? []) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed) args.push("--extractor-args", trimmed);
|
if (trimmed) args.push("--extractor-args", trimmed);
|
||||||
@@ -261,12 +274,21 @@ export async function search(
|
|||||||
requestedBy: Requester,
|
requestedBy: Requester,
|
||||||
): Promise<Track[]> {
|
): Promise<Track[]> {
|
||||||
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
|
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
|
||||||
const { stdout, stderr } = await runYtDlp([
|
// Searches run in parallel and yt-dlp rewrites the cookie file when it exits,
|
||||||
...baseArgs(),
|
// 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",
|
"--flat-playlist",
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
`${prefix}${limit}:${query}`,
|
`${prefix}${limit}:${query}`,
|
||||||
]);
|
]));
|
||||||
|
} finally {
|
||||||
|
if (cookies) await rm(cookies, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
const entries = parseNdjson(stdout);
|
const entries = parseNdjson(stdout);
|
||||||
// yt-dlp can exit 0 with nothing to show (bot checks, region blocks). Without
|
// 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.
|
// 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). */
|
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
|
||||||
export async function resolveStreamUrl(pageUrl: string): Promise<string> {
|
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);
|
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
||||||
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
|
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
|
||||||
return url;
|
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 {
|
export interface AudioProcess {
|
||||||
stream: Readable;
|
stream: Readable;
|
||||||
kill(): void;
|
kill(): void;
|
||||||
@@ -338,7 +380,7 @@ export interface AudioProcess {
|
|||||||
export function openAudioStream(pageUrl: string): AudioProcess {
|
export function openAudioStream(pageUrl: string): AudioProcess {
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
config.YTDLP_PATH,
|
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 },
|
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user