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
+22 -4
View File
@@ -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<PlaybackInput> {
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") {
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
// 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,
};
+12
View File
@@ -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";
/**