import { execFile } from "child_process"; import path from "path"; import prismMedia from "prism-media"; import { Readable } from "stream"; import { promisify } from "util"; import config from "$config.js"; import logger from "$lib/logger.js"; import MusicError from "$mlib/MusicError.js"; import { DlpDump, DlpInfo, dlpStreamOptions } from "../types.js"; import extractors from "./extractors.js"; const { FFmpeg } = prismMedia; const execFileAsync = promisify(execFile); /** * Maximum length of dlp search stack. * * @internal */ const DLPINFO_DEPTH = 3; /** * Path to the Dlp binary. * * @internal */ const DLP_BINARY = path.join(config.path.binaries, "/yt-dlp"); /** * Base arguments for Ffmpeg transcoder. * * @internal */ const FFMPEG_BASE_ARGS = [ "-analyzeduration", "0", "-loglevel", "0", "-acodec", "libopus", "-f", "opus", "-ar", "48000", "-ac", "2" ]; /** * Predicate to check whether is string a valid URL. * * @returns true if given string is a valid URL, false otherwise * * @param str - Any string * * @internal */ function isURL(str: string): boolean { return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test( str ); } /** * Sub-function of dlpInfo. * * @tutorial Shall be used only inside original function * * @returns Info of the media that related to query, and ones that cannot be found * * @param query - Dlp search request or compatible URL * @param depth - Recursion depth (force stops at 0) * * @internal */ async function dlpInfoRecursive(query: string, depth = DLPINFO_DEPTH): Promise { const error = (message: string, reason: number, ...meta: unknown[]): DlpInfo => { logger.warn(message, { reason, query, ...meta }); throw new MusicError(message, reason); }; if (!depth) { return error("Dlp: recursion depth reached", 1010); } const subprocess = await execFileAsync(DLP_BINARY, [ query, "-O", "%(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j", "-s", "--flat-playlist" ]); if (!subprocess.stdout) { return error("Dlp: no stdout", 1011); } if (subprocess.stderr) { return error("Dlp: stderr", 1012, { stderr: subprocess.stderr }); } const dumps: DlpDump[] = subprocess.stdout .trim() .split("\n") .map((out) => JSON.parse(out)); const resolveDump = async (dump: DlpDump): Promise => { if (!Object.hasOwn(extractors, dump.extractor_key)) { error("Dlp: illegal extractor", 1013); } return extractors[dump.extractor_key](dump, (subquery: string) => dlpInfoRecursive(subquery, depth - 1) ); }; const result = await Promise.all(dumps.map(resolveDump)); return result.flat(); } /** * Leverages dlp to fetch metadata of media accessible by the extractors. * * @returns Metadata to a youtube video that related to query by title or URL of findings * * @param query - Dlp search request or compatible URL * * @public */ export async function dlpInfo(query: string): Promise { return await dlpInfoRecursive(isURL(query) ? query : "ytsearch1:" + query); } /** * Creates Opus stream by downloading media via dlp. * * @returns Readable stream of requested media * * @param url - Dlp compatible URL to the requested resource * @param options - Transcoders and dlp options * * @public */ export function dlpStream(url: string, options?: dlpStreamOptions): Promise { let ffmpegArgs = FFMPEG_BASE_ARGS; if (options?.seek) { ffmpegArgs.unshift("-ss", options.seek.toString()); } if (options?.ffmpeg) { ffmpegArgs = ffmpegArgs.concat(options.ffmpeg); } return new Promise((resolve, reject) => { const subprocess = execFile( DLP_BINARY, [ url, "-o", "-", "--verbose", "-f", "bestaudio[ext=webm][acodec=opus][asr=48000]/bestaudio", "--rm-cache-dir", "-r", "1M" ], { encoding: null, maxBuffer: 25000000 } ); if (!subprocess.stdout || !subprocess.stderr) { throw new Error("No stdout"); } const ffmpegTranscoder = new FFmpeg({ args: ffmpegArgs }); const stream = subprocess.stdout.pipe(ffmpegTranscoder); const onError = (err: Error) => { if (!subprocess.killed) { subprocess.kill(); } reject(err); stream.resume(); }; subprocess .once("spawn", () => { resolve(stream); }) .on("error", onError); }); }