From 6587bcd68efcb16a94a05120654fec16cadb48e2 Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sat, 13 Jan 2024 02:18:43 +0300 Subject: [PATCH] Removing ytdl-core from dependencies --- package.json | 26 +-- pnpm-lock.yaml | 41 ---- src/modules/music/lib/fetch.ts | 249 ---------------------- src/modules/music/lib/fetch/extractors.ts | 65 ++++++ src/modules/music/lib/fetch/fetch.ts | 175 +++++++++++++++ src/modules/music/lib/player.ts | 17 +- src/modules/music/lib/types.ts | 47 ++-- 7 files changed, 281 insertions(+), 339 deletions(-) delete mode 100644 src/modules/music/lib/fetch.ts create mode 100644 src/modules/music/lib/fetch/extractors.ts create mode 100644 src/modules/music/lib/fetch/fetch.ts diff --git a/package.json b/package.json index 01538c3..3e803c5 100644 --- a/package.json +++ b/package.json @@ -1,38 +1,33 @@ { "name": "quetza", "version": "2023.1.0-pre", - "description": "Discord multipurpose bot", + "description": "Awesome Discord bot that performs various duties", "type": "module", "main": "./dist/index.js", "scripts": { "start": "node ./dist/index.js", "build": "pnpm exec tsc --build", - "clean": "rm -rf ./dist && pnpm run build", + "clean": "rm -rf ./dist", "lint": "pnpm exec eslint . && pnpm exec prettier --check .", "format": "pnpm exec eslint --fix . && pnpm exec prettier --write .", "preinstall": "pnpm dlx only-allow pnpm" }, "license": "MIT", - "keywords": [ - "discord", - "bot", - "music" - ], "repository": { "type": "git", - "url": "git+https://github.com/unknowableshade/quetza-bot.git" + "url": "git+https://github.com/unknowableshade/Quetza.git" }, "author": { - "name": "unknowableshade", - "email": "60275123+unknowableshade@users.noreply.github.com", - "url": "https://unknowableshade.ru" + "name": "Vladimir Eremin", + "email": "personal@ermnvldmr.com", + "url": "https://ermnvldmr.com" }, "bugs": { - "url": "https://github.com/unknowableshade/quetza-bot/issues" + "url": "https://github.com/unknowableshade/Quetza/issues" }, - "readme": "https://github.com/unknowableshade/quetza-bot#readme", + "readme": "https://github.com/unknowableshade/Quetza#readme", "engines": { - "node": "18.x.x", + "node": "20.x.x", "npm": "*" }, "dependencies": { @@ -42,8 +37,7 @@ "discord.js": "^14.14.1", "prism-media": "^1.3.5", "tweetnacl": "^1.0.3", - "winston": "^3.11.0", - "ytdl-core": "^4.11.5" + "winston": "^3.11.0" }, "devDependencies": { "@types/node": "^20.10.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfe5912..119ef8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,6 @@ dependencies: winston: specifier: ^3.11.0 version: 3.11.0 - ytdl-core: - specifier: ^4.11.5 - version: 4.11.5 devDependencies: "@types/node": @@ -2416,17 +2413,6 @@ packages: dependencies: yallist: 4.0.0 - /m3u8stream@0.8.6: - resolution: - { - integrity: sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA== - } - engines: { node: ">=12" } - dependencies: - miniget: 4.2.3 - sax: 1.3.0 - dev: false - /magic-bytes.js@1.6.0: resolution: { @@ -2463,14 +2449,6 @@ packages: picomatch: 2.3.1 dev: true - /miniget@4.2.3: - resolution: - { - integrity: sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA== - } - engines: { node: ">=12" } - dev: false - /minimatch@3.1.2: resolution: { @@ -3027,13 +3005,6 @@ packages: engines: { node: ">=10" } dev: false - /sax@1.3.0: - resolution: - { - integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA== - } - dev: false - /semver@6.3.1: resolution: { @@ -3673,15 +3644,3 @@ packages: } engines: { node: ">=10" } dev: true - - /ytdl-core@4.11.5: - resolution: - { - integrity: sha512-27LwsW4n4nyNviRCO1hmr8Wr5J1wLLMawHCQvH8Fk0hiRqrxuIu028WzbJetiYH28K8XDbeinYW4/wcHQD1EXA== - } - engines: { node: ">=12" } - dependencies: - m3u8stream: 0.8.6 - miniget: 4.2.3 - sax: 1.3.0 - dev: false diff --git a/src/modules/music/lib/fetch.ts b/src/modules/music/lib/fetch.ts deleted file mode 100644 index 2df4b01..0000000 --- a/src/modules/music/lib/fetch.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { execFile } from "child_process"; -import path from "path"; -import prismMedia from "prism-media"; -import { Duplex, Readable } from "stream"; -import { promisify } from "util"; -import ytdl from "ytdl-core"; - -import config from "../../../config.js"; -import logger from "../../../lib/logger.js"; -import { StreamOptions, YtdlDump, YtdlResult, YtdlStreamOptions } from "./types.js"; - -const { opus: Opus, FFmpeg } = prismMedia; -const execFileAsync = promisify(execFile); - -const SEARCH_DEPTH = 5; -const YTDL_EVENTS = [ - "info", - "progress", - "abort", - "request", - "response", - "error", - "redirect", - "retry", - "reconnect" -]; - -function isURL(url: string): boolean { - return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test( - url - ); -} - -async function fetchInfoRecursive(query: string, depth = 0): Promise { - if (depth === SEARCH_DEPTH) { - logger.warn("yt-dlp failed to find anything.", { query }); - - return []; - } - - const executable = await execFileAsync(path.join(config.binariesDir, "/yt-dlp"), [ - query, - "-O %(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j", - "-s", - "--flat-playlist" - ]); - - const dump: YtdlDump[] = JSON.parse("[" + executable.stdout.replace(/\n(?=.+)/g, ",") + "]"); - - const result: YtdlResult[] = []; - - for (const entry of dump) { - if (entry.title && entry.thumbnail && entry.duration && entry.extractor_key === "Youtube") { - result.push({ - url: entry.original_url, - title: entry.title, - thumbnail: entry.thumbnail, - duration: entry.duration - }); - - logger.info("yt-dlp has found corresponding youtube video.", { - result: result[result.length - 1] - }); - - continue; - } - - let unwind: YtdlResult[]; - - if (entry.extractor_key !== "Youtube" && entry.title) { - unwind = await fetchInfoRecursive( - `ytsearch1:${entry.uploader ? entry.uploader + " - " : ""} ${entry.title}`, - depth + 1 - ); - } else { - unwind = await fetchInfoRecursive(entry.original_url, depth + 1); - } - - if (unwind.length !== 0) { - result.push(unwind[0]); - } - } - - return result; -} - -export async function fetchInfo(query: string): Promise { - logger.info("yt-dlp was invoked.", { query }); - - try { - if (!isURL(query)) { - return await fetchInfoRecursive("ytsearch1:" + query); - } - - return await fetchInfoRecursive(query); - } catch (error) { - logger.error("yt-dlp execution invoked error.", { error }); - - return []; - } -} - -export function ytdlStream(url: string, options?: YtdlStreamOptions) { - logger.info("Created ytdl stream.", { url, options }); - - let ffmpegArgs: string[] = [ - "-analyzeduration", - "0", - "-loglevel", - "0", - "-f", - `${options?.fmt ? options.fmt : "s16le"}`, - "-ar", - "48000", - "-ac", - "2" - ]; - - if (options?.seek) { - ffmpegArgs.unshift("-ss", options.seek.toString()); - } - - if (options?.encoderArgs) { - ffmpegArgs = ffmpegArgs.concat(options.encoderArgs); - } - - const transcoder = new FFmpeg({ - args: ffmpegArgs - }); - - const inputStream = ytdl(url, options); - const transcoderStream = inputStream.pipe(transcoder); - - if (options && !options.opusEncoded) { - YTDL_EVENTS.forEach((event) => - inputStream.on(event, (...args) => transcoderStream.emit(event, ...args)) - ); - inputStream.on("error", () => transcoder.destroy()); - transcoderStream.on("close", () => transcoder.destroy()); - - return transcoderStream; - } - - const opus = new Opus.Encoder({ - rate: 48000, - channels: 2, - frameSize: 960 - }); - - const outputStream = transcoderStream.pipe(opus); - - transcoderStream.on("error", (e) => outputStream.emit("error", e)); - - YTDL_EVENTS.forEach((event) => - inputStream.on(event, (...args) => outputStream.emit(event, ...args)) - ); - - outputStream.on("close", () => { - transcoder.destroy(); - opus.destroy(); - }); - - transcoderStream.on("error", (err) => logger.error("ffmpeg", err)); - inputStream.on("error", (err) => logger.error("ytdl", err)); - outputStream.on("error", (err) => logger.error("opus", err)); - - return outputStream; -} - -export function arbitraryStream(stream: string | Readable | Duplex, options?: StreamOptions) { - logger.info("Created arbitrary stream.", { stream, options }); - - let ffmpegArgs: string[]; - - if (typeof stream === "string") { - ffmpegArgs = [ - "-reconnect", - "1", - "-reconnect_streamed", - "1", - "-reconnect_delay_max", - "5", - "-i", - stream, - "-analyzeduration", - "0", - "-loglevel", - "0", - "-f", - `${options?.fmt ? options.fmt : "s16le"}`, - "-ar", - "48000", - "-ac", - "2" - ]; - } else { - ffmpegArgs = [ - "-analyzeduration", - "0", - "-loglevel", - "0", - "-f", - `${options?.fmt ? options.fmt : "s16le"}`, - "-ar", - "48000", - "-ac", - "2" - ]; - } - - if (options?.seek) { - ffmpegArgs.unshift("-ss", options.seek.toString()); - } - - if (options?.encoderArgs) { - ffmpegArgs = ffmpegArgs.concat(options.encoderArgs); - } - - let transcoder = new FFmpeg({ - args: ffmpegArgs - }); - - if (typeof stream !== "string") { - transcoder = stream.pipe(transcoder); - - stream.on("error", () => transcoder.destroy()); - } - - if (options && !options.opusEncoded) { - transcoder.on("close", () => transcoder.destroy()); - - return transcoder; - } - - const opus = new Opus.Encoder({ - rate: 48000, - channels: 2, - frameSize: 960 - }); - - const outputStream = transcoder.pipe(opus); - - outputStream.on("close", () => { - transcoder.destroy(); - opus.destroy(); - }); - - return outputStream; -} diff --git a/src/modules/music/lib/fetch/extractors.ts b/src/modules/music/lib/fetch/extractors.ts new file mode 100644 index 0000000..d52f04b --- /dev/null +++ b/src/modules/music/lib/fetch/extractors.ts @@ -0,0 +1,65 @@ +import { DlpDump, dlpExtractorKey, DlpInfo } from "../types"; + +export const dlpExtractorsValues = ["Youtube", "Soundcloud"] as const; + +/** + * Defines structure for an extractor that will be called by dlpInfo. + * + * For example: + * + * @see {@link youtubeExtractor | the youtubeExtractor(...) function } + */ +type dlpExtractor = (arg0: DlpDump, arg1: (arg0: string) => Promise) => Promise; + +export function isDlpExtractorKey(that: string): that is dlpExtractorKey { + return dlpExtractorsValues.includes(that as dlpExtractorKey); +} + +/** + * Resolves data that was extracted by dlp (depending on extractor_key). + * + * @returns DlpInfo generated from dump provided + * + * @param dump - Parsed dlp output + * @param dlpInfo - Function to call if any other information needed + * (will recurcively call next extractor after its run) + */ +const youtubeExtractor: dlpExtractor = async (dump, dlpInfo) => { + if (dump.id && dump.title && dump.duration) { + const result = { + url: `https://www.youtube.com/watch?v=${dump.id}`, + title: dump.title, + thumbnail: `https://img.youtube.com/vi/${dump.id}/default.jpg`, + duration: dump.duration + }; + + return [[result], []]; + } + + return await dlpInfo(dump.original_url); +}; + +/** + * {@inheritdoc youtubeExtractor} + */ +const soundcloudExtractor: dlpExtractor = async (dump, dlpInfo) => { + if (dump.title && dump.duration && dump.thumbnail) { + const result = { + url: dump.webpage_url ?? dump.original_url, + title: dump.title, + thumbnail: dump.thumbnail, + duration: dump.duration + }; + + return [[result], []]; + } + + return await dlpInfo(dump.original_url); +}; + +const extractors: Record = { + Youtube: youtubeExtractor, + Soundcloud: soundcloudExtractor +}; + +export default extractors; diff --git a/src/modules/music/lib/fetch/fetch.ts b/src/modules/music/lib/fetch/fetch.ts new file mode 100644 index 0000000..b05b31d --- /dev/null +++ b/src/modules/music/lib/fetch/fetch.ts @@ -0,0 +1,175 @@ +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 { DlpDump, DlpInfo, dlpStreamOptions } from "../types.js"; +import extractors, { isDlpExtractorKey } from "./extractors.js"; + +const { FFmpeg } = prismMedia; +const execFileAsync = promisify(execFile); + +/** + * Maximum length of dlp search stack. + */ +const DLPINFO_DEPTH = 5; + +/** + * Predicate to check whether is string a valid URL. + * + * @returns true if given string is a valid URL, false otherwise + * + * @param str - Any string + */ +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) + */ +async function dlpInfoRecursive(query: string, depth = DLPINFO_DEPTH): Promise { + const error = (message: string, reason: number, ...meta: unknown[]): DlpInfo => { + logger.warn(message, { reason, query, ...meta }); + return [[], [{ query, reason }]]; + }; + + if (!depth) { + return error("Dlp: recursion depth reached", 408); + } + + const subprocess = await execFileAsync(path.join(config.binariesDir, "/yt-dlp"), [ + query, + "-O", + "%(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j", + "-s", + "--flat-playlist" + ]); + + if (!subprocess.stdout) { + return error("Dlp: no stdout", 400); + } + + if (subprocess.stderr) { + return error("Dlp: stderr", 500, { + stderr: subprocess.stderr + }); + } + + const dumps: DlpDump[] = JSON.parse("[" + subprocess.stdout.replace(/\n(?=.+)/g, ",") + "]"); + + const resolveDump = async (dump: DlpDump): Promise => { + if (!isDlpExtractorKey(dump.extractor_key)) { + throw new Error("Dlp: illegal extractor"); + } + + return extractors[dump.extractor_key](dump, (subquery: string) => + dlpInfoRecursive(subquery, depth - 1) + ); + }; + + const result = await Promise.all(dumps.map(resolveDump)).catch((err: Error): DlpInfo[] => { + return [error(err.message, 417, { query })]; + }); + + return result.reduce( + (prev, curr) => [prev[0].concat(curr[0]), prev[1].concat(curr[1])], + [[], []] + ); +} + +/** + * 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 + */ +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 + */ +export function dlpStream(url: string, options?: dlpStreamOptions) { + let ffmpegArgs: string[] = [ + "-analyzeduration", + "0", + "-loglevel", + "0", + "-acodec", + "libopus", + "-f", + "opus", + "-ar", + "48000", + "-ac", + "2" + ]; + + 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( + path.join(config.binariesDir, "/yt-dlp"), + [ + 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); + }); +} diff --git a/src/modules/music/lib/player.ts b/src/modules/music/lib/player.ts index e3bd1a5..50e6a9f 100644 --- a/src/modules/music/lib/player.ts +++ b/src/modules/music/lib/player.ts @@ -10,7 +10,7 @@ import { } from "@discordjs/voice"; import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js"; -import { fetchInfo, ytdlStream } from "./fetch.js"; +import { dlpInfo, dlpStream } from "./fetch/fetch.js"; import Filter from "./filter.js"; import { largestCommonSequence } from "./misc.js"; import Music from "./music.js"; @@ -75,16 +75,13 @@ export default class Player { this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable()); } - private resourceGenerator(track: Track, seek?: number) { - const stream = ytdlStream(track.url, { - opusEncoded: true, - filter: "audioonly", - highWaterMark: 1 << 25, + private async resourceGenerator(track: Track, seek?: number) { + const probe = await dlpStream(track.url, { seek: seek ? seek / 1000 : undefined, - encoderArgs: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined + ffmpeg: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined }); - this.resource_ = createAudioResource(stream, { + this.resource_ = createAudioResource(probe, { inlineVolume: true, metadata: track }); @@ -122,7 +119,7 @@ export default class Player { } private async search(query: string, requester: User): Promise { - const info = await fetchInfo(query); + const [info] = await dlpInfo(query); if (info.length === 0) { return undefined; @@ -140,7 +137,7 @@ export default class Player { return; } - this.player_.play(this.resourceGenerator(track, seek)); + this.player_.play(await this.resourceGenerator(track, seek)); this.connection_.subscribe(this.player_); diff --git a/src/modules/music/lib/types.ts b/src/modules/music/lib/types.ts index 6052f4a..ef113a6 100644 --- a/src/modules/music/lib/types.ts +++ b/src/modules/music/lib/types.ts @@ -1,7 +1,20 @@ import { User } from "discord.js"; -import { downloadOptions } from "ytdl-core"; -export interface Track { +import { dlpExtractorsValues } from "./fetch/extractors"; + +export interface LostTrack { + query: string; + reason: number; +} + +export interface TrackBase { + url: string; + title: string; + thumbnail: string; + duration: number; +} + +export interface Track extends TrackBase { url: string; title: string; thumbnail: string; @@ -9,35 +22,23 @@ export interface Track { requester: User; } +export type DlpInfo = [TrackBase[], LostTrack[]]; + export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO"; -export interface YtdlDump { +export type dlpExtractorKey = (typeof dlpExtractorsValues)[number]; + +export interface DlpDump { id: string; original_url: string; + webpage_url?: string; title?: string; - uploader?: string; duration?: number; - extractor_key: string; thumbnail?: string; + extractor_key: dlpExtractorKey; } -export interface YtdlResult { - url: string; - title: string; - thumbnail: string; - duration: number; -} - -export interface YtdlStreamOptions extends downloadOptions { +export interface dlpStreamOptions { seek?: number; - encoderArgs?: string[]; - fmt?: string; - opusEncoded?: boolean; -} - -export interface StreamOptions { - seek?: number; - encoderArgs?: string[]; - fmt?: string; - opusEncoded?: boolean; + ffmpeg?: string[]; }