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 { 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 { 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 { return new Promise((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 { 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 { 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 { 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; } /** 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((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 { try { const { stdout } = await runYtDlp(["--version"], 15_000); return stdout.trim() || null; } catch { return null; } }