From 71b9b7fe17f390c4e5b7b06bb5729ac65fb80933 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 9 Sep 2026 00:24:09 +0300 Subject: [PATCH] Support a proxy for networks where YouTube is unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 12 ++++++++++++ README.md | 25 +++++++++++++++++++++++++ compose.yml | 40 +++++++++++++++++++++------------------- src/config.ts | 8 ++++++++ src/index.ts | 5 ++++- src/sources/index.ts | 26 ++++++++++++++++++++++---- src/sources/ytdlp.ts | 12 ++++++++++++ 7 files changed, 104 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index 45f50c4..18e2365 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,18 @@ YTDLP_PATH=yt-dlp # Подробности — в README, раздел «Учётка YouTube (cookies)». # 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, через ";". # Помогает, когда YouTube не отдаёт форматы серверному IP: # YTDLP_EXTRACTOR_ARGS=youtube:player_client=default,web_safari diff --git a/README.md b/README.md index 01c8664..e0a1978 100644 --- a/README.md +++ b/README.md @@ -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). +## Прокси, когда 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 yt-dlp не поддерживает — единственный рабочий способ авторизоваться diff --git a/compose.yml b/compose.yml index b37690a..8c330b8 100644 --- a/compose.yml +++ b/compose.yml @@ -1,19 +1,21 @@ -name: stoat-mbot - -services: - mbot: - build: . - restart: always - env_file: .env - ports: - # Наружу не торчит: домен вешается на внешний Caddy хоста. - - "127.0.0.1:3005:3005" - extra_hosts: - # Домен Stoat резолвится в хост, где внешний Caddy держит валидный TLS. - # Так бот ходит в API, gateway и LiveKit ровно как обычный клиент, - # без зависимости от NAT loopback на роутере. - # Замените на свой домен: - - "chat.example.com:host-gateway" - volumes: - # cookies.txt для yt-dlp и/или локальная медиатека - - ./data:/data +name: stoat-mbot + +services: + mbot: + build: . + restart: always + env_file: .env + ports: + # Наружу не торчит: домен вешается на внешний Caddy хоста. + - "127.0.0.1:3005:3005" + extra_hosts: + # Домен Stoat резолвится в хост, где внешний Caddy держит валидный TLS. + # Так бот ходит в API, gateway и LiveKit ровно как обычный клиент, + # без зависимости от NAT loopback на роутере. + # Замените на свой домен: + - "chat.example.com:host-gateway" + # Раскомментируйте, если YTDLP_PROXY указывает на прокси, поднятый на хосте: + # - "host.docker.internal:host-gateway" + volumes: + # cookies.txt для yt-dlp и/или локальная медиатека + - ./data:/data diff --git a/src/config.ts b/src/config.ts index 006523a..945115e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -43,6 +43,14 @@ const schema = z.object({ YTDLP_PATH: z.string().default("yt-dlp"), 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 */ YTDLP_EXTRACTOR_ARGS: z.string().optional(), LOCAL_MEDIA_DIR: z.string().optional(), diff --git a/src/index.ts b/src/index.ts index b391425..edebad6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { startBot } from "./bot/index.js"; import { config } from "./config.js"; import { MusicManager } from "./core/manager.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 { const ytdlpVersion = await checkYtDlp(); @@ -22,6 +22,9 @@ async function main(): Promise { ); } + const proxy = describeProxy(); + if (proxy) logger.info({ proxy }, "routing yt-dlp through a proxy"); + const cookies = await checkCookies(); if (cookies === "ok") { logger.info({ path: config.YTDLP_COOKIES }, "using YouTube cookies"); diff --git a/src/sources/index.ts b/src/sources/index.ts index 33b2d96..81eab7b 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -5,7 +5,7 @@ import * as direct from "./direct.js"; import * as local from "./local.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"; const YOUTUBE_HOSTS = ["youtube.com", "youtu.be", "music.youtube.com", "m.youtube.com"]; @@ -173,6 +173,17 @@ const HTTP_RESILIENCE = [ "-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. */ export async function openPlayback(track: Track, seekSeconds = 0): Promise { const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : []; @@ -183,20 +194,27 @@ export async function openPlayback(track: Track, seekSeconds = 0): Promise {} }; + 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 // resolve the CDN URL instead and let ffmpeg do an HTTP range request. const streamUrl = await ytdlp.resolveStreamUrl(track.url); 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); return { input: proc.stream, - inputOptions: [], + inputOptions: seekOptions, cleanup: () => proc.kill(), failure: proc.failure, }; diff --git a/src/sources/ytdlp.ts b/src/sources/ytdlp.ts index d0df686..fb8f480 100644 --- a/src/sources/ytdlp.ts +++ b/src/sources/ytdlp.ts @@ -33,6 +33,7 @@ interface YtDlpEntry { function baseArgs(): string[] { const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"]; 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(";") ?? []) { const trimmed = value.trim(); if (trimmed) args.push("--extractor-args", trimmed); @@ -40,6 +41,17 @@ function baseArgs(): string[] { 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"; /**