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
+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 },
);