Document YouTube cookie auth and warn when the file is unusable
Cookies were already wired up but only mentioned in passing, and the non-obvious parts were undocumented: yt-dlp has no username/password support for YouTube, it rewrites the cookie file to persist rotated cookies (so a read-only file expires early), and the export has to happen in a private window that is logged out before closing. Startup now reports whether the cookie file is usable, missing, or read-only, and YTDLP_EXTRACTOR_ARGS is passed through for the cases where YouTube blocks a server IP outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1ac99b4a10
commit
2b999bf2a0
+248
-221
@@ -1,221 +1,248 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
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);
|
||||
return args;
|
||||
}
|
||||
|
||||
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
|
||||
reject(new UserFacingError(firstUsefulError(stderr)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function firstUsefulError(stderr: string): string {
|
||||
const line = stderr
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.toUpperCase().startsWith("ERROR"));
|
||||
if (!line) return "Не удалось получить трек";
|
||||
return line.replace(/^ERROR:\s*/i, "").slice(0, 300);
|
||||
}
|
||||
|
||||
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 = await runYtDlp([
|
||||
...baseArgs(),
|
||||
"--flat-playlist",
|
||||
"--dump-json",
|
||||
`${prefix}${limit}:${query}`,
|
||||
]);
|
||||
return parseNdjson(stdout).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): Promise<SearchResult> {
|
||||
const stdout = await runYtDlp([
|
||||
...baseArgs(),
|
||||
"--flat-playlist",
|
||||
"--dump-single-json",
|
||||
"--playlist-end",
|
||||
String(maxTracks),
|
||||
url,
|
||||
]);
|
||||
const root = parseNdjson(stdout)[0];
|
||||
if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr = (stderr + chunk).slice(-2000);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0 && code !== null && stderr.trim()) {
|
||||
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
stream: child.stdout,
|
||||
kill: () => {
|
||||
if (child.exitCode === null) child.kill("SIGKILL");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkAvailable(): Promise<string | null> {
|
||||
try {
|
||||
const out = await runYtDlp(["--version"], 15_000);
|
||||
return out.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
import { spawn } from "node:child_process";
|
||||
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);
|
||||
for (const value of config.YTDLP_EXTRACTOR_ARGS?.split(";") ?? []) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) args.push("--extractor-args", trimmed);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
|
||||
reject(new UserFacingError(firstUsefulError(stderr)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function firstUsefulError(stderr: string): string {
|
||||
const line = stderr
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.toUpperCase().startsWith("ERROR"));
|
||||
if (!line) return "Не удалось получить трек";
|
||||
return line.replace(/^ERROR:\s*/i, "").slice(0, 300);
|
||||
}
|
||||
|
||||
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 = await runYtDlp([
|
||||
...baseArgs(),
|
||||
"--flat-playlist",
|
||||
"--dump-json",
|
||||
`${prefix}${limit}:${query}`,
|
||||
]);
|
||||
return parseNdjson(stdout).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): Promise<SearchResult> {
|
||||
const stdout = await runYtDlp([
|
||||
...baseArgs(),
|
||||
"--flat-playlist",
|
||||
"--dump-single-json",
|
||||
"--playlist-end",
|
||||
String(maxTracks),
|
||||
url,
|
||||
]);
|
||||
const root = parseNdjson(stdout)[0];
|
||||
if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr = (stderr + chunk).slice(-2000);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0 && code !== null && stderr.trim()) {
|
||||
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
stream: child.stdout,
|
||||
kill: () => {
|
||||
if (child.exitCode === null) child.kill("SIGKILL");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkAvailable(): Promise<string | null> {
|
||||
try {
|
||||
const out = await runYtDlp(["--version"], 15_000);
|
||||
return out.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user