Files
stoat-mbot/src/sources/ytdlp.ts
T
Leonid PershinandClaude Opus 5 380f7bcf31 Check the proxy at startup instead of failing on first use
A proxy pointed at 127.0.0.1 from inside a container reaches the
container itself, and the only sign was a connection error buried in the
first search. Startup now probes the proxy over TCP and says what is
wrong, naming host.docker.internal when loopback was configured.

The README also covers the follow-up trap: even that address fails when
the proxy listens on loopback only, so it shows how to check the bind
address and what to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:29:36 +03:00

362 lines
13 KiB
TypeScript

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 type { Readable } from "node:stream";
import { config } from "../config.js";
import { logger } from "../logger.js";
import { UserFacingError, type Requester, type SearchResult, type SourceKind, type Track } from "../types.js";
const log = logger.child({ mod: "yt-dlp" });
/** Raw shape of the fields we consume from yt-dlp's JSON output. */
interface YtDlpEntry {
id?: string;
title?: string;
duration?: number | null;
uploader?: string | null;
channel?: string | null;
artist?: string | null;
webpage_url?: string | null;
url?: string | null;
original_url?: string | null;
thumbnail?: string | null;
thumbnails?: Array<{ url?: string }> | null;
is_live?: boolean | null;
live_status?: string | null;
extractor_key?: string | null;
ie_key?: string | null;
_type?: string;
entries?: YtDlpEntry[] | null;
}
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);
}
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)";
}
}
/**
* A quick TCP probe of the proxy. The usual mistake is pointing a container at
* 127.0.0.1, which is the container itself — better to say so at startup than to
* let every search fail with a connection error.
*/
export function checkProxy(timeoutMs = 4000): Promise<string | null> {
if (!config.YTDLP_PROXY) return Promise.resolve(null);
let target: URL;
try {
target = new URL(config.YTDLP_PROXY);
} catch {
return Promise.resolve("YTDLP_PROXY не является корректным URL");
}
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 1080);
return new Promise((resolve) => {
const socket = connect({ host: target.hostname, port });
const done = (result: string | null) => {
socket.destroy();
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => done(null));
socket.once("timeout", () => done(`прокси ${target.hostname}:${port} не отвечает`));
socket.once("error", (err: NodeJS.ErrnoException) => {
const hint =
target.hostname === "127.0.0.1" || target.hostname === "localhost"
? " — внутри контейнера это сам контейнер; используйте host.docker.internal"
: "";
done(`прокси ${target.hostname}:${port} недоступен (${err.code ?? err.message})${hint}`);
});
});
}
export type CookieStatus = "ok" | "read-only" | "missing";
/**
* yt-dlp rewrites the cookie file after every run to persist rotated cookies,
* so a read-only file quietly degrades back to anonymous access.
*/
export async function checkCookies(): Promise<CookieStatus | null> {
if (!config.YTDLP_COOKIES) return null;
try {
await access(config.YTDLP_COOKIES, constants.R_OK | constants.W_OK);
return "ok";
} catch {
try {
await access(config.YTDLP_COOKIES, constants.R_OK);
return "read-only";
} catch {
return "missing";
}
}
}
interface YtDlpRun {
stdout: string;
stderr: string;
code: number | null;
}
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<YtDlpRun> {
return new Promise<YtDlpRun>((resolve, reject) => {
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new UserFacingError("yt-dlp не ответил вовремя"));
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => (stdout += chunk));
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => (stderr += chunk));
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT") {
reject(new UserFacingError(`yt-dlp не найден (${config.YTDLP_PATH}). Проверьте YTDLP_PATH.`));
return;
}
reject(err);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0 || stdout.trim().length > 0) {
resolve({ stdout, stderr, code });
return;
}
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
reject(new UserFacingError(firstUsefulError(stderr)));
});
});
}
/** yt-dlp messages people actually hit, in words that suggest what to do. */
const ERROR_PATTERNS: Array<[RegExp, string]> = [
[/unsupported url/i, "Не понимаю эту ссылку — проверьте, что она открывается в браузере"],
[/is not a valid url/i, "Это не похоже на ссылку"],
[/video unavailable|this video is not available/i, "Видео недоступно"],
[/private video/i, "Видео приватное"],
[/members[- ]only|join this channel/i, "Видео только для участников канала"],
[/confirm your age|age[- ]restricted/i, "Возрастное ограничение — нужны cookies аккаунта (YTDLP_COOKIES)"],
[/confirm you'?re not a bot|sign in to confirm/i, "YouTube требует вход — добавьте cookies.txt (YTDLP_COOKIES)"],
[/not available in your country|geo[- ]?restricted|blocked it in your country/i, "Недоступно в регионе сервера"],
[/page needs to be reloaded/i, "YouTube сменил API — обновите yt-dlp (см. README)"],
[/unable to download|network|timed out|connection/i, "Не удалось связаться с источником"],
];
/** Percent-encoded URLs are unreadable in a chat message. */
function decodeUrls(text: string): string {
return text.replace(/https?:\/\/\S+/g, (url) => {
try {
return decodeURI(url);
} catch {
return url;
}
});
}
function firstUsefulError(stderr: string): string {
const line = stderr
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.toUpperCase().startsWith("ERROR"));
if (!line) return "Не удалось получить трек";
const raw = decodeUrls(line.replace(/^ERROR:\s*/i, "")).slice(0, 300);
const known = ERROR_PATTERNS.find(([pattern]) => pattern.test(raw));
return known ? known[1] : raw;
}
function sourceOf(entry: YtDlpEntry): SourceKind {
const key = (entry.extractor_key ?? entry.ie_key ?? "").toLowerCase();
if (key.includes("soundcloud")) return "soundcloud";
if (key.includes("youtube")) return "youtube";
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
if (url.includes("soundcloud.com")) return "soundcloud";
if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube";
return "direct";
}
function pickThumbnail(entry: YtDlpEntry): string | null {
if (entry.thumbnail) return entry.thumbnail;
const list = entry.thumbnails ?? [];
const last = list.at(-1);
return last?.url ?? null;
}
export function toTrack(entry: YtDlpEntry, requestedBy: Requester, fallbackSource?: SourceKind): Track {
const isLive = Boolean(entry.is_live) || entry.live_status === "is_live";
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
return {
id: randomUUID(),
title: entry.title?.trim() || "Без названия",
author: entry.artist ?? entry.uploader ?? entry.channel ?? null,
duration: isLive ? 0 : Math.max(0, Math.round(entry.duration ?? 0)),
isLive,
url,
thumbnail: pickThumbnail(entry),
source: fallbackSource ?? sourceOf(entry),
requestedBy,
};
}
function parseNdjson(stdout: string): YtDlpEntry[] {
const entries: YtDlpEntry[] = [];
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
entries.push(JSON.parse(trimmed) as YtDlpEntry);
} catch {
// yt-dlp occasionally interleaves non-JSON noise; skip it.
}
}
return entries;
}
export async function search(
query: string,
kind: "youtube" | "soundcloud",
limit: number,
requestedBy: Requester,
): Promise<Track[]> {
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
const { stdout, stderr } = await runYtDlp([
...baseArgs(),
"--flat-playlist",
"--dump-json",
`${prefix}${limit}:${query}`,
]);
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.
if (entries.length === 0 && stderr.trim()) {
log.warn({ query, kind, stderr: stderr.slice(0, 500) }, "search returned nothing");
throw new UserFacingError(firstUsefulError(stderr));
}
log.info({ query, kind, count: entries.length }, "search");
return entries.map((entry) => toTrack(entry, requestedBy, kind));
}
/** Resolves a URL that may point at a single track, a playlist, or an album. */
export async function resolveUrl(
url: string,
requestedBy: Requester,
maxTracks: number,
options: { singleTrack?: boolean } = {},
): Promise<SearchResult> {
const { stdout, stderr } = await runYtDlp([
...baseArgs(),
"--flat-playlist",
"--dump-single-json",
...(options.singleTrack ? ["--no-playlist"] : ["--playlist-end", String(maxTracks)]),
url,
]);
const root = parseNdjson(stdout)[0];
if (!root) {
// Typically a dead or malformed link; yt-dlp's own line says it best.
log.warn({ url, stderr: stderr.slice(0, 500) }, "url resolved to nothing");
throw new UserFacingError(
stderr.trim()
? firstUsefulError(stderr)
: "По этой ссылке ничего не открылось — проверьте, что она рабочая",
);
}
if (root._type === "playlist" && Array.isArray(root.entries)) {
const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e));
if (entries.length === 0) throw new UserFacingError("Плейлист пуст или недоступен");
return {
tracks: entries.map((entry) => toTrack(entry, requestedBy)),
playlist: {
title: root.title?.trim() || "Плейлист",
url: root.webpage_url ?? url,
trackCount: entries.length,
},
};
}
return { tracks: [toTrack(root, requestedBy)], playlist: null };
}
/** 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 url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
return url;
}
export interface AudioProcess {
stream: Readable;
kill(): void;
/** Resolves with a reason when the download fails, or null when it was fine. */
failure: Promise<string | null>;
}
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
export function openAudioStream(pageUrl: string): AudioProcess {
const child = spawn(
config.YTDLP_PATH,
[...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl],
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
);
let stderr = "";
let killed = false;
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr = (stderr + chunk).slice(-2000);
});
const failure = new Promise<string | null>((resolve) => {
child.on("close", (code) => {
if (killed || code === 0 || code === null) {
resolve(null);
return;
}
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
resolve(firstUsefulError(stderr));
});
child.on("error", (err: Error) => resolve(err.message));
});
return {
stream: child.stdout,
failure,
kill: () => {
killed = true;
if (child.exitCode === null) child.kill("SIGKILL");
},
};
}
export async function checkAvailable(): Promise<string | null> {
try {
const { stdout } = await runYtDlp(["--version"], 15_000);
return stdout.trim() || null;
} catch {
return null;
}
}