Support a proxy for networks where YouTube is unreachable

YTDLP_PROXY routes every yt-dlp call — search, metadata and the audio
stream, for YouTube and SoundCloud alike — through an http(s) or SOCKS
proxy such as a local Psiphon. Startup logs which proxy is in use with
any credentials stripped.

ffmpeg has no SOCKS support, so with a proxy configured playback always
goes through the yt-dlp pipe instead of a resolved CDN URL: nothing
escapes past the proxy, at the cost of slower seeking. Direct links keep
using ffmpeg, which gets the proxy only when it speaks http(s).

Verified against a dead proxy: requests fail through it rather than
quietly going direct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 00:24:09 +03:00
co-authored by Claude Opus 5
parent 6d65134f8e
commit 71b9b7fe17
7 changed files with 104 additions and 24 deletions
+12
View File
@@ -40,6 +40,18 @@ YTDLP_PATH=yt-dlp
# Подробности — в README, раздел «Учётка YouTube (cookies)». # Подробности — в README, раздел «Учётка YouTube (cookies)».
# YTDLP_COOKIES=/data/cookies.txt # YTDLP_COOKIES=/data/cookies.txt
# Необязательно: прокси для всех запросов yt-dlp — YouTube и SoundCloud,
# поиск, метаданные и сам аудиопоток. Пригодится, когда из сети сервера
# YouTube недоступен (Psiphon, свой SOCKS и т.п.).
# Схемы: http://, https://, socks5:// и socks5h:// (socks5h резолвит DNS на
# стороне прокси — обычно нужен именно он). Можно с логином и паролем:
# socks5h://user:pass@host:1080
# YTDLP_PROXY=socks5h://127.0.0.1:1080
#
# Внимание: 127.0.0.1 внутри контейнера — это сам контейнер. Если прокси поднят
# на хосте, используйте socks5h://host.docker.internal:1080 и добавьте в
# compose.yml к extra_hosts строку "host.docker.internal:host-gateway".
# Необязательно: дополнительные --extractor-args, через ";". # Необязательно: дополнительные --extractor-args, через ";".
# Помогает, когда YouTube не отдаёт форматы серверному IP: # Помогает, когда YouTube не отдаёт форматы серверному IP:
# YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari # YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari
+25
View File
@@ -229,6 +229,31 @@ docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compo
Актуальный тег — на [странице релизов yt-dlp](https://github.com/yt-dlp/yt-dlp/releases). Актуальный тег — на [странице релизов yt-dlp](https://github.com/yt-dlp/yt-dlp/releases).
## Прокси, когда YouTube недоступен
`YTDLP_PROXY` пропускает через прокси **все** обращения yt-dlp — поиск, метаданные и сам
аудиопоток, и для YouTube, и для SoundCloud:
```dotenv
YTDLP_PROXY=socks5h://127.0.0.1:1080
```
Схемы: `http://`, `https://`, `socks5://`, `socks5h://`; можно с логином и паролем
(`socks5h://user:pass@host:1080`). `socks5h` резолвит DNS на стороне прокси — обычно нужен
именно он, иначе имена доменов всё равно уходят в локальную сеть. При старте бот пишет в лог,
через какой прокси работает, скрывая учётные данные.
Два подводных камня:
- **`127.0.0.1` внутри контейнера — это сам контейнер.** Если Psiphon или ваш SOCKS запущен на
хосте, укажите `socks5h://host.docker.internal:1080` и добавьте в `compose.yml`
к `extra_hosts` строку `"host.docker.internal:host-gateway"`.
- **ffmpeg не умеет SOCKS.** Поэтому при заданном прокси аудио всегда идёт через yt-dlp, а не
напрямую из CDN — трафик не утекает мимо прокси. Побочный эффект: перемотка становится
медленнее, так как позиция отыгрывается декодированием, а не HTTP-запросом с диапазоном.
Прямые ссылки и интернет-радио ffmpeg скачивает сам, и там прокси применяется только для схем
`http://` и `https://`.
## Учётка YouTube (cookies) ## Учётка YouTube (cookies)
Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться Логин и пароль для YouTube yt-dlp не поддерживает — единственный рабочий способ авторизоваться
+21 -19
View File
@@ -1,19 +1,21 @@
name: stoat-mbot name: stoat-mbot
services: services:
mbot: mbot:
build: . build: .
restart: always restart: always
env_file: .env env_file: .env
ports: ports:
# Наружу не торчит: домен вешается на внешний Caddy хоста. # Наружу не торчит: домен вешается на внешний Caddy хоста.
- "127.0.0.1:3005:3005" - "127.0.0.1:3005:3005"
extra_hosts: extra_hosts:
# Домен Stoat резолвится в хост, где внешний Caddy держит валидный TLS. # Домен Stoat резолвится в хост, где внешний Caddy держит валидный TLS.
# Так бот ходит в API, gateway и LiveKit ровно как обычный клиент, # Так бот ходит в API, gateway и LiveKit ровно как обычный клиент,
# без зависимости от NAT loopback на роутере. # без зависимости от NAT loopback на роутере.
# Замените на свой домен: # Замените на свой домен:
- "chat.example.com:host-gateway" - "chat.example.com:host-gateway"
volumes: # Раскомментируйте, если YTDLP_PROXY указывает на прокси, поднятый на хосте:
# cookies.txt для yt-dlp и/или локальная медиатека # - "host.docker.internal:host-gateway"
- ./data:/data volumes:
# cookies.txt для yt-dlp и/или локальная медиатека
- ./data:/data
+8
View File
@@ -43,6 +43,14 @@ 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(),
/** Proxy for every yt-dlp request: http://, https://, socks5:// or socks5h://. */
YTDLP_PROXY: z
.string()
.optional()
.refine(
(value) => !value || /^(https?|socks[45]h?):\/\//i.test(value),
"должен начинаться с http://, https://, socks5:// или socks5h://",
),
/** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */ /** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */
YTDLP_EXTRACTOR_ARGS: z.string().optional(), YTDLP_EXTRACTOR_ARGS: z.string().optional(),
LOCAL_MEDIA_DIR: z.string().optional(), LOCAL_MEDIA_DIR: z.string().optional(),
+4 -1
View File
@@ -3,7 +3,7 @@ import { startBot } from "./bot/index.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { MusicManager } from "./core/manager.js"; import { MusicManager } from "./core/manager.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { checkCookies, checkYtDlp } from "./sources/index.js"; import { checkCookies, checkYtDlp, describeProxy } from "./sources/index.js";
async function main(): Promise<void> { async function main(): Promise<void> {
const ytdlpVersion = await checkYtDlp(); const ytdlpVersion = await checkYtDlp();
@@ -22,6 +22,9 @@ async function main(): Promise<void> {
); );
} }
const proxy = describeProxy();
if (proxy) logger.info({ proxy }, "routing yt-dlp through a proxy");
const cookies = await checkCookies(); const cookies = await checkCookies();
if (cookies === "ok") { if (cookies === "ok") {
logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies"); logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies");
+22 -4
View File
@@ -5,7 +5,7 @@ import * as direct from "./direct.js";
import * as local from "./local.js"; import * as local from "./local.js";
import * as ytdlp from "./ytdlp.js"; import * as ytdlp from "./ytdlp.js";
export { checkAvailable as checkYtDlp, checkCookies } from "./ytdlp.js"; export { checkAvailable as checkYtDlp, checkCookies, describeProxy } from "./ytdlp.js";
export { isEnabled as isLocalLibraryEnabled, listFiles as listLocalFiles } from "./local.js"; export { isEnabled as isLocalLibraryEnabled, listFiles as listLocalFiles } from "./local.js";
const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"]; const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"];
@@ -173,6 +173,17 @@ const HTTP_RESILIENCE = [
"-reconnect_delay_max", "5", "-reconnect_delay_max", "5",
]; ];
/**
* ffmpeg speaks HTTP proxies only, and just for http(s) inputs — it has no SOCKS
* support. So an http(s) proxy is handed to ffmpeg for direct links, while
* anything else keeps ffmpeg off the network entirely (see openPlayback).
*/
function ffmpegProxyOptions(): string[] {
const proxy = config.YTDLP_PROXY;
if (!proxy || !/^https?:\/\//i.test(proxy)) return [];
return ["-http_proxy", proxy];
}
/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */ /** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */
export async function openPlayback(track: Track, seekSeconds = 0): Promise<PlaybackInput> { export async function openPlayback(track: Track, seekSeconds = 0): Promise<PlaybackInput> {
const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : []; const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
@@ -183,20 +194,27 @@ export async function openPlayback(track: Track, seekSeconds = 0): Promise<Playb
} }
if (track.source === "direct") { if (track.source === "direct") {
return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} }; return {
input: track.url,
inputOptions: [...HTTP_RESILIENCE, ...ffmpegProxyOptions(), ...seekOptions],
cleanup: () => {},
};
} }
if (seekSeconds > 0) { if (seekSeconds > 0 && !config.YTDLP_PROXY) {
// Seeking over a pipe would mean decoding everything up to the offset, so we // Seeking over a pipe would mean decoding everything up to the offset, so we
// resolve the CDN URL instead and let ffmpeg do an HTTP range request. // resolve the CDN URL instead and let ffmpeg do an HTTP range request.
const streamUrl = await ytdlp.resolveStreamUrl(track.url); const streamUrl = await ytdlp.resolveStreamUrl(track.url);
return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} }; return { input: streamUrl, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
} }
// With a proxy configured we always pipe through yt-dlp, which honours it;
// handing a CDN URL to ffmpeg would leak the request past the proxy (and would
// simply fail for SOCKS). Seeking then costs a decode up to the offset.
const proc = ytdlp.openAudioStream(track.url); const proc = ytdlp.openAudioStream(track.url);
return { return {
input: proc.stream, input: proc.stream,
inputOptions: [], inputOptions: seekOptions,
cleanup: () => proc.kill(), cleanup: () => proc.kill(),
failure: proc.failure, failure: proc.failure,
}; };
+12
View File
@@ -33,6 +33,7 @@ interface YtDlpEntry {
function baseArgs(): string[] { function baseArgs(): string[] {
const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"]; const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"];
if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES); if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES);
if (config.YTDLP_PROXY) 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);
@@ -40,6 +41,17 @@ function baseArgs(): string[] {
return args; return args;
} }
/** Proxy URLs often carry credentials, which have no business in logs. */
export function describeProxy(): string | null {
if (!config.YTDLP_PROXY) return null;
try {
const url = new URL(config.YTDLP_PROXY);
return `${url.protocol}//${url.username ? "***@" : ""}${url.host}`;
} catch {
return "(некорректный URL)";
}
}
export type CookieStatus = "ok" | "read-only" | "missing"; export type CookieStatus = "ok" | "read-only" | "missing";
/** /**