Add music bot for self-hosted Stoat with web control panel
Plays audio into Stoat voice channels over LiveKit and exposes the same player through both chat commands and a browser panel, so the two never drift apart: everything routes through a single MusicManager. - core: per-server GuildPlayer (queue, loop, shuffle, seek, volume, idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg - sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet radio, optional local library with path-traversal guards - bot: 18 chat commands with aliases, plus !panel one-time login links - api: Fastify REST + WebSocket, sessions authenticated against the instance's own /auth/session/login (TOTP supported), permissions re-checked against Stoat membership and roles on every request - web: React panel with search, queue editing, seek and volume - deploy: Dockerfile, compose.override.yml and Caddyfile snippets for dropping the service into an existing /opt/stoat stack Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs (9 API checks). Voice playback itself needs a live instance to test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9d0e9f6bf
commit
a9b7ccdd16
@@ -0,0 +1,75 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { logger } from "../logger.js";
|
||||
import type { Requester, Track } from "../types.js";
|
||||
|
||||
const log = logger.child({ mod: "direct" });
|
||||
|
||||
const AUDIO_CONTENT_TYPES = [
|
||||
"audio/",
|
||||
"application/ogg",
|
||||
"application/x-mpegurl",
|
||||
"application/vnd.apple.mpegurl",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
];
|
||||
|
||||
export interface ProbeResult {
|
||||
isMedia: boolean;
|
||||
isLive: boolean;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/** Cheap HEAD probe used to tell "direct media URL" apart from "web page". */
|
||||
export async function probe(url: string): Promise<ProbeResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
headers: { "user-agent": "stoat-mbot/0.1", icy: "1" },
|
||||
});
|
||||
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
|
||||
const isMedia = AUDIO_CONTENT_TYPES.some((type) => contentType.startsWith(type));
|
||||
// Shoutcast/Icecast expose the station name and never a content length.
|
||||
const icyName = res.headers.get("icy-name");
|
||||
const isLive = isMedia && !res.headers.get("content-length");
|
||||
return { isMedia: isMedia || Boolean(icyName), isLive: isLive || Boolean(icyName), title: icyName };
|
||||
} catch (err) {
|
||||
log.debug({ err, url }, "probe failed");
|
||||
return { isMedia: false, isLive: false, title: null };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function toTrack(url: string, requestedBy: Requester, probed: ProbeResult): Track {
|
||||
let title = probed.title;
|
||||
if (!title) {
|
||||
try {
|
||||
const name = path.basename(new URL(url).pathname);
|
||||
title = decodeURIComponent(name) || new URL(url).hostname;
|
||||
} catch {
|
||||
title = url;
|
||||
}
|
||||
}
|
||||
let host: string | null = null;
|
||||
try {
|
||||
host = new URL(url).hostname;
|
||||
} catch {
|
||||
host = null;
|
||||
}
|
||||
return {
|
||||
id: randomUUID(),
|
||||
title,
|
||||
author: host,
|
||||
duration: 0,
|
||||
isLive: probed.isLive,
|
||||
url,
|
||||
thumbnail: null,
|
||||
source: "direct",
|
||||
requestedBy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import { config } from "../config.js";
|
||||
import { UserFacingError, type Requester, type SearchResult, type Track } from "../types.js";
|
||||
import * as direct from "./direct.js";
|
||||
import * as local from "./local.js";
|
||||
import * as ytdlp from "./ytdlp.js";
|
||||
|
||||
export { checkAvailable as checkYtDlp } 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"];
|
||||
const SOUNDCLOUD_HOSTS = ["soundcloud.com", "on.soundcloud.com", "m.soundcloud.com"];
|
||||
|
||||
function asUrl(value: string): URL | null {
|
||||
if (!/^https?:\/\//i.test(value)) return null;
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hostMatches(url: URL, hosts: string[]): boolean {
|
||||
const host = url.hostname.replace(/^www\./, "");
|
||||
return hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`));
|
||||
}
|
||||
|
||||
interface ParsedQuery {
|
||||
text: string;
|
||||
forced: "youtube" | "soundcloud" | "local" | null;
|
||||
}
|
||||
|
||||
function parsePrefix(raw: string): ParsedQuery {
|
||||
const trimmed = raw.trim();
|
||||
const match = /^(yt|youtube|sc|soundcloud|local|file):\s*(.+)$/is.exec(trimmed);
|
||||
if (!match) return { text: trimmed, forced: null };
|
||||
const [, prefix, rest] = match as unknown as [string, string, string];
|
||||
const key = prefix.toLowerCase();
|
||||
if (key === "sc" || key === "soundcloud") return { text: rest.trim(), forced: "soundcloud" };
|
||||
if (key === "local" || key === "file") return { text: rest.trim(), forced: "local" };
|
||||
return { text: rest.trim(), forced: "youtube" };
|
||||
}
|
||||
|
||||
/** Turns whatever a user typed into a playable set of tracks. */
|
||||
export async function resolveQuery(
|
||||
rawQuery: string,
|
||||
requestedBy: Requester,
|
||||
maxTracks = config.MAX_QUEUE_SIZE,
|
||||
): Promise<SearchResult> {
|
||||
const { text, forced } = parsePrefix(rawQuery);
|
||||
if (!text) throw new UserFacingError("Укажите название трека или ссылку");
|
||||
|
||||
if (forced === "local") {
|
||||
const tracks = await local.search(text, config.SEARCH_RESULT_LIMIT, requestedBy);
|
||||
if (tracks.length === 0) throw new UserFacingError("В медиатеке ничего не найдено");
|
||||
return { tracks: tracks.slice(0, 1), playlist: null };
|
||||
}
|
||||
|
||||
const url = asUrl(text);
|
||||
if (url) {
|
||||
if (hostMatches(url, YOUTUBE_HOSTS) || hostMatches(url, SOUNDCLOUD_HOSTS)) {
|
||||
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
|
||||
}
|
||||
const probed = await direct.probe(text);
|
||||
if (probed.isMedia) {
|
||||
return { tracks: [direct.toTrack(text, requestedBy, probed)], playlist: null };
|
||||
}
|
||||
// Not a raw media URL — let yt-dlp try its extractors (Bandcamp, Vimeo, ...).
|
||||
return ytdlp.resolveUrl(text, requestedBy, maxTracks);
|
||||
}
|
||||
|
||||
if (local.isEnabled() && forced === null) {
|
||||
const localHits = await local.search(text, 1, requestedBy);
|
||||
if (localHits.length > 0 && localHits[0]) return { tracks: [localHits[0]], playlist: null };
|
||||
}
|
||||
|
||||
const tracks = await ytdlp.search(text, forced ?? "youtube", 1, requestedBy);
|
||||
if (tracks.length === 0) throw new UserFacingError("Ничего не найдено");
|
||||
return { tracks, playlist: null };
|
||||
}
|
||||
|
||||
/** Multi-result search used by the `search` command and the web panel. */
|
||||
export async function searchTracks(
|
||||
rawQuery: string,
|
||||
requestedBy: Requester,
|
||||
limit = config.SEARCH_RESULT_LIMIT,
|
||||
): Promise<Track[]> {
|
||||
const { text, forced } = parsePrefix(rawQuery);
|
||||
if (!text) return [];
|
||||
|
||||
const url = asUrl(text);
|
||||
if (url) {
|
||||
const result = await resolveQuery(text, requestedBy);
|
||||
return result.tracks;
|
||||
}
|
||||
|
||||
if (forced === "local") return local.search(text, limit, requestedBy);
|
||||
|
||||
const [remote, localHits] = await Promise.all([
|
||||
ytdlp.search(text, forced ?? "youtube", limit, requestedBy),
|
||||
local.isEnabled() && forced === null
|
||||
? local.search(text, 3, requestedBy).catch(() => [])
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return [...localHits, ...remote].slice(0, limit);
|
||||
}
|
||||
|
||||
export interface PlaybackInput {
|
||||
/** Either a file path / URL for ffmpeg, or a piped stream. */
|
||||
input: string | Readable;
|
||||
inputOptions: string[];
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
const HTTP_RESILIENCE = [
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
];
|
||||
|
||||
/** 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)] : [];
|
||||
|
||||
if (track.source === "local") {
|
||||
const filePath = await local.assertInsideLibrary(track.url);
|
||||
return { input: filePath, inputOptions: seekOptions, cleanup: () => {} };
|
||||
}
|
||||
|
||||
if (track.source === "direct") {
|
||||
return { input: track.url, inputOptions: [...HTTP_RESILIENCE, ...seekOptions], cleanup: () => {} };
|
||||
}
|
||||
|
||||
if (seekSeconds > 0) {
|
||||
// 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: () => {} };
|
||||
}
|
||||
|
||||
const proc = ytdlp.openAudioStream(track.url);
|
||||
return { input: proc.stream, inputOptions: [], cleanup: () => proc.kill() };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { UserFacingError, type Requester, type Track } from "../types.js";
|
||||
|
||||
const log = logger.child({ mod: "local" });
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav", ".wma", ".webm"]);
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
let cache: { files: string[]; at: number } | null = null;
|
||||
|
||||
function libraryRoot(): string {
|
||||
if (!config.LOCAL_MEDIA_DIR) throw new UserFacingError("Локальная медиатека не настроена (LOCAL_MEDIA_DIR)");
|
||||
return path.resolve(config.LOCAL_MEDIA_DIR);
|
||||
}
|
||||
|
||||
async function walk(dir: string, out: string[], depth = 0): Promise<void> {
|
||||
if (depth > 6) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
log.warn({ err, dir }, "cannot read media directory");
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, out, depth + 1);
|
||||
} else if (entry.isFile() && AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFiles(force = false): Promise<string[]> {
|
||||
const root = libraryRoot();
|
||||
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.files;
|
||||
const files: string[] = [];
|
||||
await walk(root, files);
|
||||
files.sort((a, b) => a.localeCompare(b));
|
||||
cache = { files, at: Date.now() };
|
||||
return files;
|
||||
}
|
||||
|
||||
export function isEnabled(): boolean {
|
||||
return Boolean(config.LOCAL_MEDIA_DIR);
|
||||
}
|
||||
|
||||
/** Guards against path traversal — only files inside the library may be played. */
|
||||
export async function assertInsideLibrary(filePath: string): Promise<string> {
|
||||
const root = libraryRoot();
|
||||
const resolved = path.resolve(filePath);
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new UserFacingError("Файл вне медиатеки");
|
||||
}
|
||||
const info = await stat(resolved).catch(() => null);
|
||||
if (!info?.isFile()) throw new UserFacingError("Файл не найден");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function toTrack(filePath: string, requestedBy: Requester): Track {
|
||||
const root = libraryRoot();
|
||||
const relative = path.relative(root, filePath);
|
||||
const parsed = path.parse(relative);
|
||||
const parentDir = path.basename(parsed.dir);
|
||||
return {
|
||||
id: randomUUID(),
|
||||
title: parsed.name,
|
||||
author: parentDir || null,
|
||||
// Filled in from ffmpeg's codecData once playback starts.
|
||||
duration: 0,
|
||||
isLive: false,
|
||||
url: filePath,
|
||||
thumbnail: null,
|
||||
source: "local",
|
||||
requestedBy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function search(query: string, limit: number, requestedBy: Requester): Promise<Track[]> {
|
||||
const files = await listFiles();
|
||||
const needle = query.trim().toLowerCase();
|
||||
const matches = needle
|
||||
? files.filter((file) => path.basename(file).toLowerCase().includes(needle))
|
||||
: files;
|
||||
return matches.slice(0, limit).map((file) => toTrack(file, requestedBy));
|
||||
}
|
||||
|
||||
export async function resolvePath(filePath: string, requestedBy: Requester): Promise<Track> {
|
||||
const resolved = await assertInsideLibrary(filePath);
|
||||
return toTrack(resolved, requestedBy);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user