const { spawn } = require("child_process"); const { Readable } = require("stream"); const EventEmitter = require("events"); const mime = require("mime"); const path = require("path"); const http = require("http"); const https = require("https"); const { URL } = require("url"); const fs = require("fs"); const FFMPEG = require("./ffmpeg"); const formatsCache = new Map(); const infoCache = new Map(); const d = r => console.debug("DEBUG:", r) || r; async function downloadFile(fileUrl) { return new Promise((resolve, reject) => { try { const url = new URL(fileUrl); const protocol = url.protocol === "https:" ? https : http; const extMatch = path.extname(url.pathname).split("?")[0]; const extension = extMatch ? extMatch.replace(".", "") : "tmp"; const filename = `${Date.now()}.${extension}`; const savePath = path.join(__dirname, "tmp", filename); fs.mkdirSync(path.dirname(savePath), { recursive: true }); const file = fs.createWriteStream(savePath); protocol.get(fileUrl, (response) => { if (response.statusCode !== 200) { return reject( new Error(`Failed to get '${fileUrl}' (${response.statusCode})`) ); } response.pipe(file); file.on("finish", () => { file.close(); /*setTimeout(() => { fs.rm(savePath, () => {}); }, 60000);*/ resolve(savePath); }); }).on("error", (err) => { fs.unlinkSync(savePath); reject(err); }); } catch (err) { reject(err); } }); } class YtdlUpdater extends EventEmitter { constructor () { super(); this.updateRunned = false; const updInterval = 3600000 * 24; // production //const updInterval = 30000; // test this.update(); setInterval(() => this.update(), updInterval); this.locks = 0; } lock (lockedBy) { if (this.updateRunned) { return false; // Denied } this.locks++; lockedBy.once("finish", () => { if (--this.locks === 0) { this.emit("resume"); } }); return true; } async update () { if (this.updateRunned) return; this.updateRunned = true; this.emit("init"); if (this.locks > 0) await new Promise(rs => { this.once("resume", rs); }); this.emit("run"); console.info("RUN UPDATE", new Date()); const code = await new Promise((resolve, reject) => { const child = spawn("yt-dlp", ["-U"]); child.on("close", (code) => { resolve(code); }); }); if (code !== 0) console.warn("yt-dlp update failed with error: " + code); else console.info("Update finished"); this.emit("finish"); this.updateRunned = false; } } class DownloadVideo { static get qualities () { const q = { webm_best: { exc: "webm", flag: "best", mime: "video/webm", type: "video" }, mp4_best: { exc: "mp4", flag: [ "best[ext=mp4]", "--merge-output-format", "mp4" ], mime: "video/mp4", type: "video", strictName: "📹 Best video" }, audio_default: { exc: "mp4", flag: "worst", mime: "audio/mpeg", type: "audio", strictName: "🎧 Audio" }, }; q.default = q.webm_best; return q; } destructor () { CLASS .instances .pop(CLASS .instances .indexOf(this)); } constructor(url, quality) { const CLASS = this.__proto__.constructor; if (!CLASS.instances) CLASS.instances = new Array(); CLASS.instances.push(this); this.url = url; this.quality = quality; if (!this.quality) this.quality = CLASS.qualities.default; } setupParent (parent) { this.parent = parent; } async getVideoInfo() { const fromCache = infoCache.get(this.url); if (fromCache) return fromCache; const signal = new EventEmitter(); if (!this.parent.updater.lock(signal)) { await new Promise((rs => { this.parent.updater.once("finish", () => { this.parent.updater.lock(this); rs(); }); })); } const result = await new Promise((resolve, reject) => { const child = spawn("yt-dlp", [ "--cookies", "cookies.txt", "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "-j", this.url ]); let data = ""; child.stdout.on("data", chunk => { data += chunk.toString(); }); child.stderr.on("data", err => { console.error("yt-dlp error:", err.toString()); }); child.on("close", code => { if (code !== 0) return reject(new Error("yt-dlp failed to get info")); try { const info = JSON.parse(data); resolve({ id: info.id, title: info.title, description: info.description, uploader: info.uploader, duration: info.duration, thumbnail: info.thumbnails?.[info.thumbnails.length - 1]?.url || info.thumbnail, url: info.webpage_url, thumbnails: info.thumbnails }); } catch (e) { reject(e); } }); }); infoCache.set(this.url, result); signal.emit("finish"); return result; } async getFormats() { const fromCache = formatsCache.get(this.url); if (fromCache) return fromCache; const signal = new EventEmitter(); if (!this.parent.updater.lock(signal)) { await new Promise((rs => { this.parent.updater.once("finish", () => { this.parent.updater.lock(this); rs(); }); })); } const result = await new Promise((resolve, reject) => { const child = spawn("yt-dlp", [ "--cookies", "cookies.txt", "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "-j", this.url ]); let data = ""; child.stdout.on("data", (chunk) => (data += chunk.toString())); child.on("close", (code) => { if (code !== 0) return reject(new Error("yt-dlp failed")); const info = JSON.parse(data); resolve(info.formats); }); }); formatsCache.set(this.url, result); signal.emit("finish"); return result; } async qualities(withDefaults = false) { const CLASS = this.__proto__.constructor; const formats = await this.getFormats(); const q = {}; formats.forEach(f => { if (!f.ext || !f.format_id) return; const resolution = f.height ? `${f.height}p` : 'audio-only'; let flag = `bestaudio[ext=${f.ext}]`; if (f.height) { flag = [ `best[height<=${ f.height }]`, "--merge-output-format", f.ext ]; } const mimeType = mime.getType(f.ext); const type = (resolution === "audio-only" || /^audio\//m.test(mimeType)) ? "audio" : ( /^video\//m.test(mimeType) ? "video" : null ); q[f.ext + "_" + resolution] = { format_id: f.format_id, flag, exc: f.ext, resolution, filesize: f.filesize || null, mime: mimeType, type: !mimeType ? null : type, forceAll: () => f }; }); if (withDefaults) { for (let quality in CLASS.qualities) { if (!q[quality]) q[quality] = CLASS.qualities[quality]; } } return q; } async setupQuality (quality, check=true) { if (!check) { this.quality = quality; return true; } const CLASS = this.__proto__.constructor; const all = Object.assign({}, (await this.qualities()), CLASS.qualities); if (all[quality.exc + "_" + quality.resolution]) { this.quality = quality; return true; } return true; } async download(cb, isStream = false) { const signal = new EventEmitter(); const isPath = typeof isStream === "string"; if (!this.parent.updater.lock(signal)) { await new Promise((rs => { this.parent.updater.once("finish", () => { this.parent.updater.lock(this); rs(); }); })); } const data = await new Promise(async (resolve, reject) => { const flags = !Array.isArray(this.quality.flag) ? [this.quality.flag] : this.quality.flag; const params = [ "--cookies", "cookies.txt", "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "--extractor-args", "youtube:player_client=default,mweb", "-f", ...flags, "-o", !isPath ? "-" : isStream, this.url, ]; d("yt-dlp " + params.join(" ")); const child = spawn("yt-dlp", params, { env: { ...process.env, LANG: "en_US.UTF-8", }, stdio: ["pipe","pipe","pipe"], }); let downloaded = 0; let percent = 0; let sizeTg = 0; let fine = false; const getDwnld = () => `Real size: ${downloaded}, total: ${sizeTg}`; const stderrHandler = (data) => { const str = data.toString(); const match = str.match(/(\d+\.\d+)%/); if (match && cb) { percent = parseFloat(match[1]); cb({ percent, downloaded: !isPath ? getDwnld() : "Processing", fine }); } }; child.stderr.on("data", stderrHandler); if (isPath) { child.stdout.on("data", stderrHandler); child.on("close", async (code) => { signal.emit("finish"); fine = true; if (code !== 0) { reject(new Error(`yt-dlp exited with code ${code}`)); return cb({ percent, downloaded, fine, msg: "Download stream failed" }); } cb({ percent: 100, downloaded, fine: true }); if (this.quality.type !== "audio") return resolve(isStream); const ffmpeg = new FFMPEG(isStream); const fileConverted = isStream + ".mp3"; const videoInfo = await this.getVideoInfo(); return ffmpeg.convertMusic(FFMPEG.fromFile( isStream, fileConverted, videoInfo.thumbnail )).then(readyFile => { return resolve(readyFile); }).catch(err => { reject(err); }); }); return; } else if (isStream) { console.warn("May be unstable with stream"); const stream = new Readable({ read() {} }); const ffmpeg = new FFMPEG(stream); child.stdout.on("data", (chunk) => { stream.push(chunk); downloaded += chunk.length; cb({ percent, downloaded: getDwnld(), fine }); }); child.on("close", (code) => { stream.push(null); signal.emit("finish"); fine = true; if (code !== 0) { reject(new Error(`yt-dlp exited with code ${code}`)); return cb({ percent, downloaded, fine, msg: "Download stream failed" }); } cb({ percent: 100, downloaded, fine: true }); }); child.on("error", reject); const resultedStream = this.quality.type === "video" ? (await ffmpeg.convert()) : stream; resultedStream.on("data", chunk => { sizeTg += chunk.length; if (sizeTg > 50 * 1024 * 1024) { fine = true; cb({ percent, downloaded, fine, msg: "Download failed: 50MB Telegram limit reached" }); resultedStream.push(null); } }); return resolve(resultedStream); } console.warn("May be unstable with Buffer"); const chunks = []; child.stdout.on("data", (chunk) => { chunks.push(chunk); downloaded += chunk.length; cb({ percent, downloaded, fine: false }); }); child.on("close", (code) => { fine = true; signal.emit("finish"); cb({ percent: 100, downloaded, fine }); if (code === 0) { if (isPath) { } resolve(Buffer.concat(chunks)); } else { reject(new Error(`yt-dlp exited with code ${code}`)); } }); }); const result = { format: this.quality, data, }; return result; } } module.exports = { DownloadVideo, YtdlUpdater, downloadFile };